Author: cbegin Date: Sat May 20 20:52:53 2006 New Revision: 408127 URL: http://svn.apache.org/viewvc?rev=408127&view=rev Log: First import of iBATIS for Ruby (rBatis)
Added: ibatis/trunk/rb/rbatis/ ibatis/trunk/rb/rbatis/generators/ ibatis/trunk/rb/rbatis/generators/rbatis/ ibatis/trunk/rb/rbatis/generators/rbatis/USAGE ibatis/trunk/rb/rbatis/generators/rbatis/rbatis_generator.rb ibatis/trunk/rb/rbatis/generators/rbatis/templates/ ibatis/trunk/rb/rbatis/generators/rbatis/templates/model.rb ibatis/trunk/rb/rbatis/generators/rbatis/templates/unit_test.rb ibatis/trunk/rb/rbatis/init.rb ibatis/trunk/rb/rbatis/lib/ ibatis/trunk/rb/rbatis/lib/rbatis/ ibatis/trunk/rb/rbatis/lib/rbatis.rb ibatis/trunk/rb/rbatis/lib/rbatis/rails_integration.rb ibatis/trunk/rb/rbatis/lib/rbatis/sanitizer.rb ibatis/trunk/rb/rbatis/test/ ibatis/trunk/rb/rbatis/test/rbatis_test.rb Added: ibatis/trunk/rb/rbatis/generators/rbatis/USAGE URL: http://svn.apache.org/viewvc/ibatis/trunk/rb/rbatis/generators/rbatis/USAGE?rev=408127&view=auto ============================================================================== --- ibatis/trunk/rb/rbatis/generators/rbatis/USAGE (added) +++ ibatis/trunk/rb/rbatis/generators/rbatis/USAGE Sat May 20 20:52:53 2006 @@ -0,0 +1,8 @@ +Description: + Generates a RBatis::Base stub. + +Examples: + ./script/generate rbatis order + will generate: + /app/models/order.rb + /test/unit/order.rb Added: ibatis/trunk/rb/rbatis/generators/rbatis/rbatis_generator.rb URL: http://svn.apache.org/viewvc/ibatis/trunk/rb/rbatis/generators/rbatis/rbatis_generator.rb?rev=408127&view=auto ============================================================================== --- ibatis/trunk/rb/rbatis/generators/rbatis/rbatis_generator.rb (added) +++ ibatis/trunk/rb/rbatis/generators/rbatis/rbatis_generator.rb Sat May 20 20:52:53 2006 @@ -0,0 +1,16 @@ +class RbatisGenerator < Rails::Generator::NamedBase + def manifest + record do |m| + # Check for class naming collisions. + m.class_collisions class_path, class_name, "#{class_name}Test" + + # Model, test, and fixture directories. + m.directory File.join('app/models', class_path) + m.directory File.join('test/unit', class_path) + + # Model class, unit test, and fixtures. + m.template 'model.rb', File.join('app/models', class_path, "#{file_name}.rb") + m.template 'unit_test.rb', File.join('test/unit', class_path, "#{file_name}_test.rb") + end + end +end Added: ibatis/trunk/rb/rbatis/generators/rbatis/templates/model.rb URL: http://svn.apache.org/viewvc/ibatis/trunk/rb/rbatis/generators/rbatis/templates/model.rb?rev=408127&view=auto ============================================================================== --- ibatis/trunk/rb/rbatis/generators/rbatis/templates/model.rb (added) +++ ibatis/trunk/rb/rbatis/generators/rbatis/templates/model.rb Sat May 20 20:52:53 2006 @@ -0,0 +1,2 @@ +class <%= class_name %> < RBatis::Base +end Added: ibatis/trunk/rb/rbatis/generators/rbatis/templates/unit_test.rb URL: http://svn.apache.org/viewvc/ibatis/trunk/rb/rbatis/generators/rbatis/templates/unit_test.rb?rev=408127&view=auto ============================================================================== --- ibatis/trunk/rb/rbatis/generators/rbatis/templates/unit_test.rb (added) +++ ibatis/trunk/rb/rbatis/generators/rbatis/templates/unit_test.rb Sat May 20 20:52:53 2006 @@ -0,0 +1,10 @@ +require File.dirname(__FILE__) + '<%= '/..' * class_nesting_depth %>/../test_helper' + +class <%= class_name %>Test < Test::Unit::TestCase + fixtures :<%= table_name %> + + # Replace this with your real tests. + def test_truth + assert true + end +end Added: ibatis/trunk/rb/rbatis/init.rb URL: http://svn.apache.org/viewvc/ibatis/trunk/rb/rbatis/init.rb?rev=408127&view=auto ============================================================================== --- ibatis/trunk/rb/rbatis/init.rb (added) +++ ibatis/trunk/rb/rbatis/init.rb Sat May 20 20:52:53 2006 @@ -0,0 +1,2 @@ +require 'rbatis' +require 'rbatis/rails_integration' Added: ibatis/trunk/rb/rbatis/lib/rbatis.rb URL: http://svn.apache.org/viewvc/ibatis/trunk/rb/rbatis/lib/rbatis.rb?rev=408127&view=auto ============================================================================== --- ibatis/trunk/rb/rbatis/lib/rbatis.rb (added) +++ ibatis/trunk/rb/rbatis/lib/rbatis.rb Sat May 20 20:52:53 2006 @@ -0,0 +1,327 @@ +require 'rbatis/sanitizer' + +def Fixnum.from_database(record, column) + record[column].to_i +end + +def String.from_database(record, column) + record[column].to_s +end + +def Time.from_database(record, column) + Time.parse(record[column]) +end + +module RBatis + class BooleanMapper + def from_database(record, column) + return null if record[column].nil? + return true if record[column] == '1' + return false if record[column] == '0' + + raise "can't parse boolean value for column " + column + end + end + + class Statement + attr_accessor :connection_provider + attr_reader :proc + attr_reader :execution_count + + include Sanitizer + + def initialize(params, &proc) + params.each{|k,v| send("#{k}=", v)} + @proc = proc + reset_statistics + end + + def connection + connection_provider.connection + end + + def execute(*args) + @execution_count += 1 + sql = sanitize_sql(proc.call(*args)) + do_execute(sql) + end + + def reset_statistics + @execution_count = 0 + end + + def validate + end + end + + class SelectValue < Statement + attr_accessor :result_type + + def do_execute(sql) + raise "result_type must be specified" unless result_type + record = connection.select_one(sql) + result_type.from_database(record, record.keys.first) + end + end + + class Insert < Statement + def do_execute(sql) + connection.insert(sql) + end + end + + class Delete < Statement + def do_execute(sql) + connection.delete(sql) + end + end + + class Update < Statement + def do_execute(sql) + connection.update(sql) + end + end + + class Select < Statement + attr_accessor :resultmap + + def do_execute(sql) + connection.select_all(sql).collect{|record| resultmap.map(record)}.uniq + end + + def validate + raise 'resultmap has not been specified' unless resultmap + end + end + + class SelectOne < Statement + attr_accessor :resultmap + + def do_execute(sql) + record = connection.select_one(sql) + return nil unless record + resultmap.map(record) + end + end + + class Statement + SHORTCUTS = { + :select => Select, + :select_one => SelectOne, + :select_value => SelectValue, + :insert => Insert, + :delete => Delete, + :update => Update, + } + end + + class ResultMap + attr_reader :fields + attr_reader :factory + + def initialize(factory, fields) + @factory = factory + @fields = {} + fields.each do |name, field_spec| + if field_spec.is_a?(Array) + @fields[name] = Column.new(*field_spec) + else + @fields[name] = field_spec + end + @fields[name].name = name + end + end + + def map(record) + hydrate(factory.get_or_allocate(self, record), record) + end + + def hydrate(result, record) + fields.each_value{|f| f.map(record, result)} + result.on_load if result.respond_to?(:on_load) + result + end + + def value_of(name, record) + fields[name].value(record) + end + + # Creates a new ResultMap that is identical to the previous one + # except that all columns are prefixed with the specified +prefix+. + # Use with EagerAssociation to fetch associated items from an OUTER JOIN fetch + # to accomplish eager loading and avoiding the N+1 select problem. + def prefix(prefix) + ResultMap.new(factory, fields.collect{|n,f| [n, f.prefix(prefix)]}) + end + + # Creates a new ResultMap containing all the same fields except those overriden + # by +fields+. + def extend(overriding_fields) + ResultMap.new(factory, fields.merge(overriding_fields)) + end + + def all_nil?(record) + fields.each_value{|f| return false if !f.value(record).nil?} + return true + end + end + + class Column + attr_accessor :name + attr_reader :column + attr_reader :type + + def initialize(column, type) + @column = column + @type = type + end + + def map(record, result) + result.instance_variable_set("@#{name}".to_sym, value(record)) + end + + def value(record) + type.from_database(record, column) + end + + # Creates a new column mapping with the column name prefixed with +prefix+. + def prefix(prefix) + self.class.new(prefix + column, type) + end + end + + class LazyLoadProxy + def initialize(loader, container) + @loader = loader + @container = container + end + + def method_missing(name, *args, &proc) + maybe_load + @target.send(name, *args, &proc) + end + + def to_s + maybe_load + @target.to_s + end + + private + + def maybe_load + @target = load if !defined?(@target) + end + + def load + @loader.load(@container) + end + end + + class LazyAssociation + attr_accessor :name + + def initialize(options={}, &loader) + @options = options + @options[:keys] = @options[:keys] || [EMAIL PROTECTED]:key]] + @loader = loader + end + + def map(record, result) + # association has already been loaded, don't overwrite with proxy + return if result.instance_variable_get("@#{name}".to_sym) + + result.instance_variable_set("@#{name}".to_sym, LazyLoadProxy.new(self, result)) + end + + def load(container) + return @loader.call if @loader + + keys = @options[:keys].collect{|key| container.instance_variable_get("@#{key}".to_sym)} + @options[:to].send(@options[:select], *keys) + end + end + + class EagerAssociation + attr_accessor :name + attr_reader :resultmap + + def initialize(resultmap) + @resultmap = resultmap + end + + def map(record, result) + ary = result.instance_variable_get("@#{name}".to_sym) + ary = [] if ary.nil? + ary << resultmap.map(record) unless resultmap.all_nil?(record) + result.instance_variable_set("@#{name}".to_sym, ary) + end + end + + module Repository + + def self.included(included_into) + included_into.instance_variable_set(:@resultmaps, {}) + included_into.instance_variable_set(:@statements, {}) + class <<included_into + + def statements + @statements + end + + alias selects statements + alias inserts statements + alias updates statements + + def resultmaps + @resultmaps + end + + def maps(cls) + @maps = cls + end + + def mapped_class + @maps || self + end + + def boolean + BooleanMapper.new + end + + def get_or_allocate(recordmap, record) + mapped_class.allocate + end + + def resultmap(name = :default, fields = {}) + resultmaps[name] = ResultMap.new(self, fields) + end + + def extend_resultmap(name, base, fields) + resultmaps[name] = base.extend(fields) + end + + def statement(statement_type, name = statement_type, params = {}, &proc) + statement_type = Statement::SHORTCUTS[statement_type] unless statement_type.respond_to?(:new) + statement = statement_type.new(params, &proc) + statement.connection_provider = self + statement.resultmap = resultmaps[:default] if statement.respond_to?(:resultmap=) && statement.resultmap.nil? + statement.validate + statements[name] = statement + eval <<-EVAL + def #{name}(*args) + statements[:#{name}].execute(*args) + end + EVAL + end + + def create(*args, &proc) + mapped_class.new(*args, &proc) + end + + def reset_statistics + selects.each_value{|s| s.reset_statistics} + end + end + end + end +end Added: ibatis/trunk/rb/rbatis/lib/rbatis/rails_integration.rb URL: http://svn.apache.org/viewvc/ibatis/trunk/rb/rbatis/lib/rbatis/rails_integration.rb?rev=408127&view=auto ============================================================================== --- ibatis/trunk/rb/rbatis/lib/rbatis/rails_integration.rb (added) +++ ibatis/trunk/rb/rbatis/lib/rbatis/rails_integration.rb Sat May 20 20:52:53 2006 @@ -0,0 +1,74 @@ +class Dispatcher + def reset_application! + Controllers.clear! + Dependencies.clear + ActiveRecord::Base.reset_subclasses + Dependencies.remove_subclasses_for(ActiveRecord::Base, ActiveRecord::Observer, ActionController::Base) + Dependencies.remove_subclasses_for(RBatis::Base) + Dependencies.remove_subclasses_for(ActionMailer::Base) if defined?(ActionMailer::Base) + end +end + +module RBatis + class Base + include Repository + include ::Reloadable::Subclasses + + cattr_accessor :logger + + def initialize(attributes={}) + self.attributes = attributes + end + + def attributes=(attributes) + attributes.each do |key, value| + send("#{key}=", value) + end + end + + def self.inherited(inherited_by) + RBatis::Repository.included(inherited_by) + class <<inherited_by + def connection + ActiveRecord::Base.connection + end + + def human_attribute_name(field) + field + end + end + end + + def save! + save + end + + def save + if new_record? + id = self.class.insert(self) + @new_record = false + id + else + self.class.update(self) + end + end + + def update_attribute(name, value) + send(name.to_s + '=', value) + save + end + + def on_load + @new_record = false + end + + def new_record? + return true if not defined?(@new_record) + @new_record + end + + include ActiveRecord::Validations + end +end + +RBatis::Base.logger = ActiveRecord::Base.logger \ No newline at end of file Added: ibatis/trunk/rb/rbatis/lib/rbatis/sanitizer.rb URL: http://svn.apache.org/viewvc/ibatis/trunk/rb/rbatis/lib/rbatis/sanitizer.rb?rev=408127&view=auto ============================================================================== --- ibatis/trunk/rb/rbatis/lib/rbatis/sanitizer.rb (added) +++ ibatis/trunk/rb/rbatis/lib/rbatis/sanitizer.rb Sat May 20 20:52:53 2006 @@ -0,0 +1,64 @@ +module RBatis + module Sanitizer + # Accepts an array or string. The string is returned untouched, but the array has each value + # sanitized and interpolated into the sql statement. + # ["name='%s' and group_id='%s'", "foo'bar", 4] returns "name='foo''bar' and group_id='4'" + def sanitize_sql(ary) + return ary unless ary.is_a?(Array) + + statement, *values = ary + if values.first.is_a?(Hash) and statement =~ /:\w+/ + replace_named_bind_variables(statement, values.first) + elsif statement.include?('?') + replace_bind_variables(statement, values) + else + statement % values.collect { |value| connection.quote_string(value.to_s) } + end + end + + module_function :sanitize_sql + + def replace_bind_variables(statement, values) + raise_if_bind_arity_mismatch(statement, statement.count('?'), values.size) + bound = values.dup + statement.gsub('?') { quote_bound_value(bound.shift) } + end + + def replace_named_bind_variables(statement, bind_vars) + raise_if_bind_arity_mismatch(statement, statement.scan(/:(\w+)/).uniq.size, bind_vars.size) + statement.gsub(/:(\w+)/) do + match = $1.to_sym + if bind_vars.has_key?(match) + quote_bound_value(bind_vars[match]) + else + raise PreparedStatementInvalid, "missing value for :#{match} in #{statement}" + end + end + end + + def quote_bound_value(value) + case value + when Array + value.map { |v| connection.quote(v) }.join(',') + else + connection.quote(value) + end + end + + def raise_if_bind_arity_mismatch(statement, expected, provided) + unless expected == provided + raise PreparedStatementInvalid, "wrong number of bind variables (#{provided} for #{expected}) in: #{statement}" + end + end + + def extract_options_from_args!(args) + if args.last.is_a?(Hash) then args.pop else {} end + end + + def encode_quoted_value(value) + quoted_value = connection.quote(value) + quoted_value = "'#{quoted_value[1..-2].gsub(/\'/, "\\\\'")}'" if quoted_value.include?("\\\'") + quoted_value + end + end +end \ No newline at end of file Added: ibatis/trunk/rb/rbatis/test/rbatis_test.rb URL: http://svn.apache.org/viewvc/ibatis/trunk/rb/rbatis/test/rbatis_test.rb?rev=408127&view=auto ============================================================================== --- ibatis/trunk/rb/rbatis/test/rbatis_test.rb (added) +++ ibatis/trunk/rb/rbatis/test/rbatis_test.rb Sat May 20 20:52:53 2006 @@ -0,0 +1,161 @@ +require File.expand_path(File.dirname(__FILE__) + '/../../../rails/activerecord/lib/active_record') +require 'test/unit' +require 'test/unit/ui/console/testrunner' +$: << File.dirname(__FILE__) + '/../lib/' +require 'rbatis' +require 'rbatis/rails_integration' + +RAILS_ENV = "development" +ActiveRecord::Base.configurations = { + RAILS_ENV => { + 'adapter' => 'sqlite3', + 'database' => ":memory:" + } +} +ActiveRecord::Base.establish_connection + +ActiveRecord::Base.connection.create_table :cars do |t| + t.column :make, :string + t.column :owner_id, :integer +end +ActiveRecord::Base.connection.create_table :people do |t| + t.column :name, :string +end +ActiveRecord::Base.connection.insert("INSERT INTO people (id, name) VALUES (1, 'Jon Tirsen')") +ActiveRecord::Base.connection.insert("INSERT INTO people (id, name) VALUES (2, 'Asa Holmstrom')") +ActiveRecord::Base.connection.insert("INSERT INTO people (id, name) VALUES (3, 'Ben Hogan')") +ActiveRecord::Base.connection.insert("INSERT INTO cars (id, make, owner_id) VALUES (1, 'Honda', 1)") +ActiveRecord::Base.connection.insert("INSERT INTO cars (id, make, owner_id) VALUES (2, 'Audi', 1)") +ActiveRecord::Base.connection.insert("INSERT INTO cars (id, make, owner_id) VALUES (3, 'Hyundai', 3)") + + +class Car < RBatis::Base + attr_reader :make + + def to_s + @make + end + + resultmap :default, + :id => ["id", Fixnum], + :make => ["make", String] + statement :select, :find_by_owner_id do |person_id| + ["SELECT * FROM cars WHERE owner_id = ?", person_id] + end +end + +class Person < RBatis::Base + attr_reader :person_id + attr_accessor :name + attr_accessor :cars + + def initialize(name) + @name = name + end + + def to_s + "[EMAIL PROTECTED]([EMAIL PROTECTED](',')})" + end + + resultmap :default, + :person_id => ["id", Fixnum], + :name => ["name", String], + :cars => RBatis::LazyAssociation.new(:to => Car, :select => :find_by_owner_id, :key => :person_id) + + extend_resultmap :fetch_cars, resultmaps[:default], + :cars => RBatis::EagerAssociation.new(Car.resultmaps[:default].prefix("car_")) + + statement :select, :find_all do + "SELECT * FROM people ORDER BY id" + end + + statement :select_one, :find_by_id do |id| + ["SELECT * FROM people WHERE id = ?", id] + end + + statement :select, :find_all_fetch_cars, :resultmap => Car.resultmaps[:fetch_cars] do %{ + SELECT p.*, c.id car_id, c.make car_make + FROM people p + LEFT OUTER JOIN cars c ON p.id = c.owner_id + } end + + statement :insert do |person| + ["INSERT INTO people (name) VALUES (?)", person.name] + end + + statement :update do |person| + ["UPDATE people SET name = ? WHERE id = ?", person.name, person.person_id] + end + + statement :delete do |person| + ["DELETE FROM people WHERE id = ?", person.person_id] + end + + statement :delete, :delete_temporary_data do + ["DELETE FROM people WHERE id > ?", 3] + end +end + +class RBatisTest < Test::Unit::TestCase + def setup + Person.reset_statistics + Car.reset_statistics + end + + def test_lazy_load + assert_correct_people_and_cars(Person.find_all) + assert_equal(1, Person.selects[:find_all].execution_count) + assert_equal(3, Car.selects[:find_by_owner_id].execution_count) + end + + def doesnt_work_yet_test_eager_load + assert_correct_people_and_cars(Person.find_all_fetch_cars) + + assert_equal(1, Person.selects[:find_all_fetch_cars].execution_count) + assert_equal(0, Car.selects[:find_by_owner_id].execution_count) + end + + def doesnt_work_yet_test_load_twice_gives_same_object + RBatis::Base.transaction do + assert_all_same(PersonRepository.find_all, PersonRepository.find_all) + end + end + + def test_create_update_and_delete + person = Person.new("Jon Tirsen") + id = person.save + assert(!person.new_record?) + assert(id > 0) + + person = Person.find_by_id(id) + assert_equal("Jon Tirsen", person.name) + person.name = "Julian Boot" + person.save + + person = Person.find_by_id(id) + assert_equal("Julian Boot", person.name) + Person.delete(person) + + assert_nil(Person.find_by_id(id)) + end + + def assert_all_same(ary1, ary2) + ary1.each_with_index do |el1, index| + assert_same(el1, ary2[index]) + end + end + + def assert_correct_people_and_cars(all) + assert_equal('Jon Tirsen', all[0].name) + assert_equal(2, all[0].cars.size) + assert_equal('Honda', all[0].cars[0].make) + assert_equal('Audi', all[0].cars[1].make) + assert_equal('Asa Holmstrom', all[1].name) + assert_equal(0, all[1].cars.size) + assert_equal('Ben Hogan', all[2].name) + assert_equal(1, all[2].cars.size) + assert_equal('Hyundai', all[2].cars[0].make) + p all.collect{|p| p.name} + assert_equal(3, all.size) + end +end