├── spec ├── rcov.opts ├── spec.opts ├── spec_helper.rb ├── schema_spec.rb ├── plugins_spec.rb ├── relationships_spec.rb ├── relations_spec.rb ├── relationships │ ├── abstract_relationship_spec.rb │ └── join_table_spec.rb ├── caching_spec.rb ├── base_spec.rb ├── hooks_spec.rb ├── validations_spec.rb ├── model_spec.rb └── record_spec.rb ├── autotest ├── discover.rb └── rspec_sequel.rb ├── ROADMAP ├── lib ├── sequel_model │ ├── validations.rb │ ├── relationships │ │ ├── has_one.rb │ │ ├── has_many.rb │ │ ├── block.rb │ │ ├── scoping.rb │ │ ├── join_table.rb │ │ └── relationship.rb │ ├── caching.rb │ ├── schema.rb │ ├── plugins.rb │ ├── hooks.rb │ ├── pretty_table.rb │ ├── base.rb │ ├── relationships.rb │ ├── relations.rb │ └── record.rb └── sequel_model.rb ├── TODO ├── COPYING ├── CHANGELOG ├── extra └── stats.rb ├── Rakefile └── README /spec/rcov.opts: -------------------------------------------------------------------------------- 1 | --exclude 2 | gems 3 | --exclude 4 | spec -------------------------------------------------------------------------------- /autotest/discover.rb: -------------------------------------------------------------------------------- 1 | Autotest.add_discovery do 2 | "sequel" 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --colour 2 | --backtrace 3 | --format 4 | specdoc 5 | --diff -------------------------------------------------------------------------------- /ROADMAP: -------------------------------------------------------------------------------- 1 | === 0.2.0 2 | 3 | * Overhaul of Model code. 4 | 5 | === 0.3.0 6 | 7 | * More adapters. 8 | 9 | === 0.4.0 10 | 11 | * Refactor Schema. 12 | 13 | * Database reflection. 14 | 15 | * Model validation 16 | 17 | * Refactor spec directory -------------------------------------------------------------------------------- /lib/sequel_model/validations.rb: -------------------------------------------------------------------------------- 1 | gem "assistance", ">= 0.1.2" # because we need Validations 2 | 3 | require "assistance" 4 | 5 | module Sequel 6 | class Model 7 | include Validation 8 | 9 | alias_method :save!, :save 10 | def save(*args) 11 | return false unless valid? 12 | save!(*args) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Observer pattern: 2 | 3 | class MyObserver < Sequel::Observer 4 | observe Item, Post 5 | 6 | def after_save 7 | ... 8 | end 9 | end 10 | 11 | * Smarter one_to_one: stuff like: 12 | 13 | # assuming the Author's primary key is :name 14 | post = Post.create(:name => 'The Lover', :author => Author['A.B. Yehoshua']) 15 | post.set(:author => Author['A.B. Yehoshua']) 16 | 17 | Book.filter_by_author(Author[:name => 'Kurt Vonnegut']).print 18 | 19 | #and also 20 | post = Post.create(:name => 'The Lover', :author => 'A.B. Yehoshua') #=> fetches the author record 21 | post.set(:author => 'A.B. Yehoshua') 22 | 23 | * many_to_many smart relationships. 24 | -------------------------------------------------------------------------------- /lib/sequel_model/relationships/has_one.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | class HasOne < Relationship 4 | 5 | def arity ; :one ; end 6 | 7 | # Post.author = @author 8 | def set(other) 9 | other.save if other.new? 10 | unless options[:type] == :simple 11 | # store in foreign key of other table 12 | else 13 | # store in join table 14 | end 15 | end 16 | 17 | def define_relationship_accessor(options = {}) 18 | klass.class_eval "def #{@relation} ; #{reader(options[:type])} ; end" 19 | klass.class_eval "def #{@relation}=(value) ; #{writer(options[:type])} ; end" 20 | end 21 | 22 | end 23 | 24 | class BelongsTo < HasOne ; end 25 | end 26 | end -------------------------------------------------------------------------------- /lib/sequel_model/relationships/has_many.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | class HasMany < Relationship 4 | 5 | def arity ; :many ; end 6 | 7 | # Post.comments.create(:body => "") 8 | def create(*args) 9 | self.<< @destination.create(*args) 10 | end 11 | 12 | # Post.comments << @comment 13 | # inserts the class into the join table 14 | # sets the other's foreign key field if options[:simple] 15 | def <<(other) 16 | other.save if other.new? 17 | # add the other object to the relationship set by inserting into the join table 18 | end 19 | 20 | def define_relationship_accessor(options = {}) 21 | klass.class_eval "def #{@relation} ; #{reader(options[:type])} ; end" 22 | end 23 | 24 | end 25 | end 26 | end -------------------------------------------------------------------------------- /lib/sequel_model/relationships/block.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | 4 | # Creates the relationships block which helps you organize your relationships in your model 5 | # 6 | # class Post 7 | # relationships do 8 | # has :many, :comments 9 | # end 10 | # end 11 | def self.relationships(&block) 12 | RelationshipsBlock::Generator.new(self, &block) if block_given? 13 | @relationships 14 | end 15 | 16 | module RelationshipsBlock 17 | class Generator 18 | def initialize(model_class, &block) 19 | @model_class = model_class 20 | instance_eval(&block) 21 | end 22 | 23 | def method_missing(method, *args) 24 | @model_class.send(method, *args) 25 | end 26 | end 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | unless Object.const_defined?('Sequel') 3 | require 'sequel_core' 4 | end 5 | require File.join(File.dirname(__FILE__), "../lib/sequel_model") 6 | 7 | class MockDataset < Sequel::Dataset 8 | def insert(*args) 9 | @db.execute insert_sql(*args) 10 | end 11 | 12 | def update(*args) 13 | @db.execute update_sql(*args) 14 | end 15 | 16 | def fetch_rows(sql) 17 | @db.execute(sql) 18 | yield({:id => 1, :x => 1}) 19 | end 20 | end 21 | 22 | class MockDatabase < Sequel::Database 23 | attr_reader :sqls 24 | 25 | def execute(sql) 26 | @sqls ||= [] 27 | @sqls << sql 28 | end 29 | 30 | def reset 31 | @sqls = [] 32 | end 33 | 34 | def transaction; yield; end 35 | 36 | def dataset; MockDataset.new(self); end 37 | end 38 | 39 | Sequel::Model.db = MODEL_DB = MockDatabase.new -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2007-2008 Sharon Rosner 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /lib/sequel_model/caching.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | def self.set_cache(store, opts = {}) 4 | @cache_store = store 5 | if (ttl = opts[:ttl]) 6 | set_cache_ttl(ttl) 7 | end 8 | 9 | meta_def(:[]) do |*args| 10 | if (args.size == 1) && (Hash === (h = args.first)) 11 | return dataset[h] 12 | end 13 | 14 | unless obj = @cache_store.get(cache_key_from_values(args)) 15 | obj = dataset[primary_key_hash((args.size == 1) ? args.first : args)] 16 | @cache_store.set(cache_key_from_values(args), obj, cache_ttl) 17 | end 18 | obj 19 | end 20 | 21 | class_def(:set) {|v| store.delete(cache_key); super} 22 | class_def(:save) {store.delete(cache_key); super} 23 | class_def(:delete) {store.delete(cache_key); super} 24 | end 25 | 26 | def self.set_cache_ttl(ttl) 27 | @cache_ttl = ttl 28 | end 29 | 30 | def self.cache_store 31 | @cache_store 32 | end 33 | 34 | def self.cache_ttl 35 | @cache_ttl ||= 3600 36 | end 37 | 38 | def self.cache_key_from_values(values) 39 | "#{self}:#{values.join(',')}" 40 | end 41 | end 42 | end -------------------------------------------------------------------------------- /lib/sequel_model/schema.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | # Defines a table schema (see Schema::Generator for more information). 4 | # 5 | # This is only needed if you want to use the create_table or drop_table 6 | # methods. 7 | def self.set_schema(name = nil, &block) 8 | if name 9 | set_dataset(db[name]) 10 | end 11 | @schema = Schema::Generator.new(db, &block) 12 | if @schema.primary_key_name 13 | set_primary_key @schema.primary_key_name 14 | end 15 | end 16 | 17 | # Returns table schema for direct descendant of Model. 18 | def self.schema 19 | @schema || ((superclass != Model) && (superclass.schema)) 20 | end 21 | 22 | # Returns name of table. 23 | def self.table_name 24 | dataset.opts[:from].first 25 | end 26 | 27 | # Returns true if table exists, false otherwise. 28 | def self.table_exists? 29 | db.table_exists?(table_name) 30 | end 31 | 32 | # Creates table. 33 | def self.create_table 34 | db.create_table_sql_list(table_name, *schema.create_info).each {|s| db << s} 35 | end 36 | 37 | # Drops table. 38 | def self.drop_table 39 | db.execute db.drop_table_sql(table_name) 40 | end 41 | 42 | # Like create_table but invokes drop_table when table_exists? is true. 43 | def self.create_table! 44 | drop_table if table_exists? 45 | create_table 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/sequel_model/plugins.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | module Plugins; end 3 | 4 | class Model 5 | class << self 6 | # Loads a plugin for use with the model class, passing optional arguments 7 | # to the plugin. 8 | def is(plugin, *args) 9 | m = plugin_module(plugin) 10 | if m.respond_to?(:apply) 11 | m.apply(self, *args) 12 | end 13 | if m.const_defined?("InstanceMethods") 14 | class_def(:"#{plugin}_opts") {args.first} 15 | include(m::InstanceMethods) 16 | end 17 | if m.const_defined?("ClassMethods") 18 | meta_def(:"#{plugin}_opts") {args.first} 19 | metaclass.send(:include, m::ClassMethods) 20 | end 21 | if m.const_defined?("DatasetMethods") 22 | unless @dataset 23 | raise Sequel::Error, "Plugin cannot be applied because the model class has no dataset" 24 | end 25 | dataset.meta_def(:"#{plugin}_opts") {args.first} 26 | dataset.metaclass.send(:include, m::DatasetMethods) 27 | end 28 | end 29 | alias_method :is_a, :is 30 | 31 | # Returns the module for the specified plugin. If the module is not 32 | # defined, the corresponding plugin gem is automatically loaded. 33 | def plugin_module(plugin) 34 | module_name = plugin.to_s.gsub(/(^|_)(.)/) {$2.upcase} 35 | if not Sequel::Plugins.const_defined?(module_name) 36 | require plugin_gem(plugin) 37 | end 38 | Sequel::Plugins.const_get(module_name) 39 | end 40 | 41 | # Returns the gem name for the given plugin. 42 | def plugin_gem(plugin) 43 | "sequel_#{plugin}" 44 | end 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /lib/sequel_model/hooks.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | HOOKS = [ 4 | :after_initialize, 5 | :before_create, 6 | :after_create, 7 | :before_update, 8 | :after_update, 9 | :before_save, 10 | :after_save, 11 | :before_destroy, 12 | :after_destroy 13 | ] 14 | 15 | # Some fancy code generation here in order to define the hook class methods... 16 | HOOK_METHOD_STR = %Q{ 17 | def self.%s(method = nil, &block) 18 | unless block 19 | (raise SequelError, 'No hook method specified') unless method 20 | block = proc {send method} 21 | end 22 | add_hook(%s, &block) 23 | end 24 | } 25 | 26 | def self.def_hook_method(m) #:nodoc: 27 | instance_eval(HOOK_METHOD_STR % [m.to_s, m.inspect]) 28 | end 29 | 30 | HOOKS.each {|h| define_method(h) {}} 31 | HOOKS.each {|h| def_hook_method(h)} 32 | 33 | # Returns the hooks hash for the model class. 34 | def self.hooks #:nodoc: 35 | @hooks ||= Hash.new {|h, k| h[k] = []} 36 | end 37 | 38 | def self.add_hook(hook, &block) #:nodoc: 39 | chain = hooks[hook] 40 | chain << block 41 | define_method(hook) do 42 | return false if super == false 43 | chain.each {|h| break false if instance_eval(&h) == false} 44 | end 45 | end 46 | 47 | # Returns true if the model class or any of its ancestors have defined 48 | # hooks for the given hook key. Notice that this method cannot detect 49 | # hooks defined using overridden methods. 50 | def self.has_hooks?(key) 51 | has = hooks[key] && !hooks[key].empty? 52 | has || ((self != Model) && superclass.has_hooks?(key)) 53 | end 54 | end 55 | end -------------------------------------------------------------------------------- /spec/schema_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | describe Sequel::Model, "table_exists?" do 4 | 5 | before(:each) do 6 | MODEL_DB.reset 7 | @model = Class.new(Sequel::Model(:items)) 8 | end 9 | 10 | it "should get the table name and question the model's db if table_exists?" do 11 | @model.should_receive(:table_name).and_return(:items) 12 | @model.db.should_receive(:table_exists?) 13 | @model.table_exists? 14 | end 15 | 16 | end 17 | 18 | describe Sequel::Model, "create_table" do 19 | 20 | before(:each) do 21 | MODEL_DB.reset 22 | @model = Class.new(Sequel::Model) do 23 | set_dataset MODEL_DB[:items] 24 | set_schema do 25 | text :name 26 | float :price, :null => false 27 | end 28 | end 29 | end 30 | 31 | it "should get the create table SQL list from the db and execute it line by line" do 32 | @model.create_table 33 | MODEL_DB.sqls.should == ['CREATE TABLE items (name text, price float NOT NULL)'] 34 | end 35 | 36 | end 37 | 38 | describe Sequel::Model, "drop_table" do 39 | 40 | before(:each) do 41 | MODEL_DB.reset 42 | @model = Class.new(Sequel::Model(:items)) 43 | end 44 | 45 | it "should get the drop table SQL for the associated table and then execute the SQL." do 46 | @model.should_receive(:table_name).and_return(:items) 47 | @model.db.should_receive(:drop_table_sql).with(:items) 48 | @model.db.should_receive(:execute).and_return(:true) 49 | @model.drop_table 50 | end 51 | 52 | end 53 | 54 | describe Sequel::Model, "create_table!" do 55 | 56 | before(:each) do 57 | MODEL_DB.reset 58 | @model = Class.new(Sequel::Model(:items)) 59 | end 60 | 61 | it "should drop table if it exists and then create the table" do 62 | @model.should_receive(:table_exists?).and_return(true) 63 | @model.should_receive(:drop_table).and_return(true) 64 | @model.should_receive(:create_table).and_return(true) 65 | 66 | @model.create_table! 67 | end 68 | 69 | end 70 | -------------------------------------------------------------------------------- /spec/plugins_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | module Sequel::Plugins 4 | 5 | module Timestamped 6 | def self.apply(m, opts) 7 | m.class_def(:get_stamp) {@values[:stamp]} 8 | m.meta_def(:stamp_opts) {opts} 9 | m.before_save {@values[:stamp] = Time.now} 10 | end 11 | 12 | module InstanceMethods 13 | def abc; timestamped_opts; end 14 | end 15 | 16 | module ClassMethods 17 | def deff; timestamped_opts; end 18 | end 19 | 20 | module DatasetMethods 21 | def ghi; timestamped_opts; end 22 | end 23 | end 24 | 25 | end 26 | 27 | describe Sequel::Model, "using a plugin" do 28 | 29 | it "should fail if the plugin is not found" do 30 | proc do 31 | c = Class.new(Sequel::Model) do 32 | is :something_or_other 33 | end 34 | end.should raise_error(LoadError) 35 | end 36 | 37 | it "should apply the plugin to the class" do 38 | c = nil 39 | proc do 40 | c = Class.new(Sequel::Model) do 41 | set_dataset MODEL_DB[:items] 42 | is :timestamped, :a => 1, :b => 2 43 | end 44 | end.should_not raise_error(LoadError) 45 | 46 | c.should respond_to(:stamp_opts) 47 | c.stamp_opts.should == {:a => 1, :b => 2} 48 | 49 | # instance methods 50 | m = c.new 51 | m.should respond_to(:get_stamp) 52 | m.should respond_to(:abc) 53 | m.abc.should == {:a => 1, :b => 2} 54 | t = Time.now 55 | m[:stamp] = t 56 | m.get_stamp.should == t 57 | 58 | # class methods 59 | c.should respond_to(:deff) 60 | c.deff.should == {:a => 1, :b => 2} 61 | 62 | # dataset methods 63 | c.dataset.should respond_to(:ghi) 64 | c.dataset.ghi.should == {:a => 1, :b => 2} 65 | end 66 | 67 | it "should fail to apply if the plugin has DatasetMethod and the model has no datset" do 68 | proc do 69 | Class.new(Sequel::Model) do 70 | is :timestamped, :a => 1, :b => 2 71 | end 72 | end.should raise_error(Sequel::Error) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/sequel_model/relationships/scoping.rb: -------------------------------------------------------------------------------- 1 | # Authors: 2 | # Mike Ferrier (http://www.mikeferrier.ca) 3 | # Hampton Catlin (http://www.hamptoncatlin.com) 4 | 5 | module ScopedStruct 6 | 7 | module ClassMethods 8 | def scope(scope_name, &block) 9 | MethodCarrier.set_scoped_methods(scope_name, block) 10 | self.extend MethodCarrier 11 | self.send(:define_method, scope_name) do 12 | ProxyObject.new(self, scope_name) 13 | end 14 | end 15 | end 16 | 17 | class ProxyObject 18 | def initialize(parent, scope_name) 19 | @parent, @scope_name = parent, scope_name 20 | end 21 | 22 | def method_missing(name, *args, &block) 23 | @parent.send(@scope_name.to_s + "_" + name.to_s, *args, &block) 24 | end 25 | end 26 | 27 | module MethodCarrier 28 | def self.extend_object(base) 29 | @@method_names.each do |method_name| 30 | base.class_eval %Q( 31 | alias #{@@scope_name + '_' + method_name} #{method_name} 32 | undef #{method_name} 33 | ) 34 | end 35 | end 36 | 37 | def self.set_scoped_methods(scope_name, method_declarations) 38 | raise SyntaxError.new("No block passed to scope command.") if method_declarations.nil? 39 | @@scope_name = scope_name.to_s 40 | @@method_names = extract_method_names(method_declarations).collect{|m| m.to_s} 41 | raise SyntaxError.new("No methods defined in scope block.") unless @@method_names.any? 42 | method_declarations.call 43 | end 44 | 45 | def self.extract_method_names(method_declarations) 46 | cls = BlankSlate.new 47 | original_methods = cls.methods 48 | cls.extend(Module.new(&method_declarations)) 49 | cls.methods - original_methods 50 | end 51 | 52 | # Jim Weirich's BlankSlate class from http://onestepback.org/index.cgi/Tech/Ruby/BlankSlate.rdoc 53 | # We use a slightly modified version of it to figure out what methods were defined in the scope block 54 | class BlankSlate 55 | instance_methods.each { |m| undef_method m unless m =~ /^(__|methods|extend)/ } 56 | end 57 | end 58 | end 59 | 60 | Object.extend(ScopedStruct::ClassMethods) 61 | -------------------------------------------------------------------------------- /lib/sequel_model/pretty_table.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | # Prints nice-looking plain-text tables 3 | # +--+-------+ 4 | # |id|name | 5 | # |--+-------| 6 | # |1 |fasdfas| 7 | # |2 |test | 8 | # +--+-------+ 9 | module PrettyTable 10 | def self.records_columns(records) 11 | columns = [] 12 | records.each do |r| 13 | if Array === r && (k = r.keys) 14 | return k 15 | elsif Hash === r 16 | r.keys.each {|k| columns << k unless columns.include?(k)} 17 | end 18 | end 19 | columns 20 | end 21 | 22 | def self.column_sizes(records, columns) 23 | sizes = Hash.new {0} 24 | columns.each do |c| 25 | s = c.to_s.size 26 | sizes[c.to_sym] = s if s > sizes[c.to_sym] 27 | end 28 | records.each do |r| 29 | columns.each do |c| 30 | s = r[c].to_s.size 31 | sizes[c.to_sym] = s if s > sizes[c.to_sym] 32 | end 33 | end 34 | sizes 35 | end 36 | 37 | def self.separator_line(columns, sizes) 38 | l = '' 39 | '+' + columns.map {|c| '-' * sizes[c]}.join('+') + '+' 40 | end 41 | 42 | def self.format_cell(size, v) 43 | case v 44 | when Bignum, Fixnum 45 | "%#{size}d" % v 46 | when Float 47 | "%#{size}g" % v 48 | else 49 | "%-#{size}s" % v.to_s 50 | end 51 | end 52 | 53 | def self.data_line(columns, sizes, record) 54 | '|' + columns.map {|c| format_cell(sizes[c], record[c])}.join('|') + '|' 55 | end 56 | 57 | def self.header_line(columns, sizes) 58 | '|' + columns.map {|c| "%-#{sizes[c]}s" % c.to_s}.join('|') + '|' 59 | end 60 | 61 | def self.print(records, columns = nil) # records is an array of hashes 62 | columns ||= records_columns(records) 63 | sizes = column_sizes(records, columns) 64 | 65 | puts separator_line(columns, sizes) 66 | puts header_line(columns, sizes) 67 | puts separator_line(columns, sizes) 68 | records.each {|r| puts data_line(columns, sizes, r)} 69 | puts separator_line(columns, sizes) 70 | end 71 | end 72 | end 73 | 74 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | === SVN 2 | 3 | * Fixed Model.implicit_table_name to disregard namespaces. 4 | 5 | === 0.4.1 (2008-02-10) 6 | 7 | * Implemented Model#inspect (#151). 8 | 9 | * Changed Model#method_missing to short-circuit and bypass checking #columns if the values hash already contains the relevant column (#150). 10 | 11 | * Updated to reflect changes in sequel_core (Dataset#clone_merge renamed to Dataset#clone). 12 | 13 | === 0.4 (2008-02-05) 14 | 15 | * Fixed Model#set to work with string keys (#143). 16 | 17 | * Fixed Model.create to correctly initialize instances marked as new (#135). 18 | 19 | * Fixed Model#initialize to convert string keys into symbol keys. This also fixes problem with validating objects initialized with string keys (#136). 20 | 21 | === 0.3.3 (2008-01-25) 22 | 23 | * Finalized support for virtual attributes. 24 | 25 | === 0.3.2.1 (2008-01-24) 26 | 27 | * Fixed Model.dataset to correctly set the dataset if using implicit naming or inheriting the superclass dataset (thanks celldee). 28 | 29 | === 0.3.2 (2008-01-24) 30 | 31 | * Added Model#update_with_params method with support for virtual attributes and auto-filtering of unrelated parameters, and changed Model.create_with_params to support virtual attributes (#128). 32 | 33 | * Cleaned up gem spec (#132). 34 | 35 | * Removed validations code. Now relying on validations in assistance gem. 36 | 37 | === 0.3.1 (2008-01-21) 38 | 39 | * Changed Model.dataset to use inflector to pluralize the class name into the table name. Works in similar fashion to table names in AR or DM. 40 | 41 | === 0.3 (2008-01-18) 42 | 43 | * Implemented Validatable::Errors class. 44 | 45 | * Added Model#reload as alias to Model#refresh. 46 | 47 | * Changed Model.create to accept a block (#126). 48 | 49 | * Rewrote validations. 50 | 51 | * Fixed Model#initialize to accept nil values (#115). 52 | 53 | === 0.2 (2008-01-02) 54 | 55 | * Removed deprecated Model.recreate_table method. 56 | 57 | * Removed deprecated :class and :on options from one_to_many macro. 58 | 59 | * Removed deprecated :class option from one_to_one macro. 60 | 61 | * Removed deprecated Model#pkey method. 62 | 63 | * Changed dependency to sequel_core. 64 | 65 | * Removed examples from sequel core. 66 | 67 | * Additional specs. We're now at 100% coverage. 68 | 69 | * Refactored hooks code. Hooks are now inheritable, and can be defined by supplying a block or a method name, or by overriding the hook instance method. Hook chains can now be broken by returning false (#111, #112). 70 | 71 | === 0.1 (2007-12-30) 72 | 73 | * Moved model code from sequel into separate model sub-project. -------------------------------------------------------------------------------- /spec/relationships_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | __END__ 4 | 5 | # class Post < Sequel::Model 6 | # relationships do 7 | # has :one, :blog, :required => true, :normalized => false # uses a blog_id field, which cannot be null, in the Post model 8 | # has :one, :account # uses a join table called accounts_posts to link the post with it's account. 9 | # has :many, :comments # uses a comments_posts join table 10 | # has :many, :authors, :required => true # authors_posts join table, requires at least one author 11 | # end 12 | # end 13 | 14 | class User < Sequel::Model; end 15 | 16 | describe Sequel::Model, "relationships" do 17 | 18 | describe "has" do 19 | before(:each) do 20 | @one = mock(Sequel::Model::HasOneRelationship) 21 | @many = mock(Sequel::Model::HasManyRelationship) 22 | end 23 | 24 | it "should allow for arity :one with options" do 25 | @one.should_receive(:create) 26 | Sequel::Model::HasOneRelationship.should_receive(:new).with(User, :site, {}).and_return(@one) 27 | User.send(:has, :one, :site) 28 | end 29 | 30 | it "should allow for arity :many with options" do 31 | @many.should_receive(:create) 32 | Sequel::Model::HasManyRelationship.should_receive(:new).with(User, :sites, {}).and_return(@many) 33 | User.send(:has, :many, :sites) 34 | end 35 | 36 | it "should raise an error Sequel::Error, \"Arity must be specified {:one, :many}.\" if arity was not specified." do 37 | lambda { User.send(:has, :so_many, :sites) }.should raise_error Sequel::Error, "Arity must be specified {:one, :many}." 38 | end 39 | end 40 | 41 | describe "has_one" do 42 | it "should pass arguments to has :one" do 43 | User.should_receive(:has).with(:one, :boss, {}).and_return(true) 44 | User.send(:has_one, :boss) 45 | end 46 | end 47 | 48 | describe "has_many" do 49 | it "should pass arguments to has :many" do 50 | User.should_receive(:has).with(:many, :addresses, {}).and_return(true) 51 | User.send(:has_many, :addresses) 52 | end 53 | end 54 | 55 | describe "belongs_to" do 56 | it "should pass arguments to has :one" do 57 | @belongs_to_relationship = mock(Sequel::Model::BelongsToRelationship) 58 | @belongs_to_relationship.should_receive(:create) 59 | Sequel::Model::BelongsToRelationship.should_receive(:new).with(User, :boss, {}).and_return(@belongs_to_relationship) 60 | User.send(:belongs_to, :boss) 61 | end 62 | end 63 | 64 | describe "relationships block" do 65 | 66 | it "should store the relationship" do 67 | User.should_receive(:has).with(:one, :boss).and_return(true) 68 | class User 69 | relationships do 70 | has :one, :boss 71 | end 72 | end 73 | # User.model_relationships.should eql(?) 74 | end 75 | it "should create relationship methods on the model" 76 | it "should allow for has :one relationship" 77 | it "should allow for has :many relationship" 78 | it "should allow for has_one relationship" 79 | it "should allow for has_many relationship" 80 | it "should allow for belongs_to" 81 | end 82 | 83 | end 84 | -------------------------------------------------------------------------------- /extra/stats.rb: -------------------------------------------------------------------------------- 1 | # Unashamedly appropriated from Rails 2 | 3 | class CodeStatistics 4 | def initialize(*pairs) 5 | @pairs = pairs 6 | @statistics = calculate_statistics 7 | @total = calculate_total if pairs.length > 1 8 | end 9 | 10 | def to_s 11 | print_header 12 | @statistics.each{ |k, v| print_line(k, v) } 13 | print_splitter 14 | 15 | if @total 16 | print_line('Total', @total) 17 | print_splitter 18 | print_code_to_test 19 | end 20 | end 21 | 22 | private 23 | def calculate_statistics 24 | @pairs.inject({}) do |stats, pair| 25 | stats[pair.first] = calculate_directory_statistics(pair.last); stats 26 | end 27 | end 28 | 29 | def get_file_statistics(fn, stats) 30 | f = File.open(fn) 31 | while line = f.gets 32 | stats[:lines] += 1 33 | stats[:classes] += 1 if line =~ /class [A-Z]/ || line =~ /context/ 34 | stats[:methods] += 1 if line =~ /def [a-z]/ || line =~ /specify/ 35 | stats[:codelines] += 1 unless line =~ /^\s*$/ || line =~ /^\s*#/ 36 | end 37 | end 38 | 39 | def get_directory_statistics(dir, stats) 40 | Dir.foreach(dir) do |fn| 41 | next if fn =~ /^\./ 42 | fn = File.join(dir, fn) 43 | if File.directory?(fn) 44 | get_directory_statistics fn, stats 45 | else 46 | next unless fn =~ /.*rb/ 47 | get_file_statistics fn, stats 48 | end 49 | end 50 | stats 51 | end 52 | 53 | def calculate_directory_statistics(directory, pattern = /.*rb/) 54 | stats = { :lines => 0, :codelines => 0, :classes => 0, :methods => 0 } 55 | get_directory_statistics directory, stats 56 | stats 57 | end 58 | 59 | def calculate_total 60 | total = { :lines => 0, :codelines => 0, :classes => 0, :methods => 0 } 61 | @statistics.each_value { |pair| pair.each { |k, v| total[k] += v } } 62 | total 63 | end 64 | 65 | def print_header 66 | print_splitter 67 | puts '| Name | Lines | LOC | Classes | Methods | M/C | LOC/M |' 68 | print_splitter 69 | end 70 | 71 | def print_splitter 72 | puts '+---------------+-------+-------+---------+---------+-----+-------+' 73 | end 74 | 75 | def print_line(name, statistics) 76 | m_over_c = (statistics[:methods] / statistics[:classes]) rescue m_over_c = 0 77 | loc_over_m = (statistics[:codelines] / statistics[:methods]) - 2 rescue loc_over_m = 0 78 | 79 | puts "| #{name.ljust(13)} " + 80 | "| #{statistics[:lines].to_s.rjust(5)} " + 81 | "| #{statistics[:codelines].to_s.rjust(5)} " + 82 | "| #{statistics[:classes].to_s.rjust(7)} " + 83 | "| #{statistics[:methods].to_s.rjust(7)} " + 84 | "| #{m_over_c.to_s.rjust(3)} " + 85 | "| #{loc_over_m.to_s.rjust(5)} |" 86 | end 87 | 88 | def print_code_to_test 89 | c_loc = 0 90 | t_loc = 0 91 | @statistics.each do |n, s| 92 | if n =~ /spec/i 93 | t_loc += s[:codelines] 94 | else 95 | c_loc += s[:codelines] 96 | end 97 | end 98 | ratio = (((t_loc.to_f / c_loc)*10).round.to_f/10).to_s[0,4] 99 | puts " Code LOC: #{c_loc} Spec LOC: #{t_loc} Code to Spec Ratio: 1:#{ratio}" 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/sequel_model/relationships/join_table.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | 4 | # Handles join tables. 5 | # Parameters are the first class and second class: 6 | # 7 | # @join_table = JoinTable.new :post, :comment 8 | # 9 | # The join table class object is available via 10 | # 11 | # @join_table.class #=> PostComment 12 | # 13 | class JoinTable 14 | 15 | attr_accessor :join_class 16 | attr_accessor :source 17 | attr_accessor :destination 18 | attr_accessor :options 19 | 20 | def self.keys(klass) 21 | singular_klass = Inflector.singularize(klass.table_name) 22 | [klass.primary_key].flatten.map do |key| 23 | [ singular_klass, key.to_s ].join("_") 24 | end 25 | end 26 | 27 | def initialize(source, destination, options = {}) 28 | @source = source 29 | @destination = destination 30 | @options = options 31 | end 32 | 33 | def source_class 34 | @source_class ||= Inflector.constantize(Inflector.classify(@source)) 35 | end 36 | 37 | def destination_class 38 | @destination_class ||= Inflector.constantize(Inflector.classify(@destination)) 39 | end 40 | 41 | def join_class 42 | # Automatically Define the JoinClass if it does not exist 43 | instance_eval <<-JOINCLASS 44 | unless defined?(::#{source_class}#{destination_class}) 45 | @join_class = 46 | class ::#{source_class}#{destination_class} < Sequel::Model 47 | set_primary_key [:#{(self.class.keys(source_class) + self.class.keys(destination_class)).join(", :")}] 48 | end 49 | else 50 | @join_class = ::#{source_class}#{destination_class} 51 | end 52 | JOINCLASS 53 | end 54 | 55 | # Outputs the join table name 56 | # which is sorted alphabetically with each table name pluralized 57 | # Examples: 58 | # join_table(user, post) #=> :posts_users 59 | # join_table(users, posts) #=> :posts_users 60 | def name 61 | [source_class.table_name.to_s, destination_class.table_name.to_s].sort.join("_") 62 | end 63 | 64 | def create(hash = {}) 65 | @join_class.new(hash).save 66 | end 67 | 68 | # creates a join table 69 | def create_table 70 | if !exists? 71 | # tablename_key1, tablename_key2,... 72 | # TODO: Inflect!, define a method to return primary_key as an array 73 | instance_eval <<-JOINTABLE 74 | db.create_table name.to_sym do 75 | #{source_class.primary_keys_hash.reverse.join(" :#{Inflector.singularize(source_class.table_name)}_")}, :null => false 76 | #{destination_class.primary_keys_hash.reverse.join(" :#{Inflector.singularize(destination_class.table_name)}_")}, :null => false 77 | end 78 | JOINTABLE 79 | true 80 | else 81 | false 82 | end 83 | end 84 | 85 | # drops the the table if it exists and creates a new one 86 | def create_table! 87 | db.drop_table name if exists? 88 | create_table 89 | end 90 | 91 | # returns true if exists, false if not 92 | def exists? 93 | self.db[name.to_sym].table_exists? 94 | end 95 | 96 | def db 97 | source_class.db 98 | end 99 | 100 | end 101 | 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /autotest/rspec_sequel.rb: -------------------------------------------------------------------------------- 1 | require "autotest" 2 | 3 | class RspecCommandError < StandardError; end 4 | 5 | class Autotest::RspecSequel < Autotest 6 | 7 | def initialize(kernel=Kernel, separator=File::SEPARATOR, alt_separator=File::ALT_SEPARATOR) # :nodoc: 8 | super() 9 | @kernel, @separator, @alt_separator = kernel, separator, alt_separator 10 | @spec_command = spec_command 11 | 12 | # watch out: Ruby bug (1.8.6): 13 | # %r(/) != /\// 14 | # since Ruby compares the REGEXP source, not the resulting pattern 15 | @test_mappings = { 16 | %r%^spec/.*_spec\.rb$% => kernel.proc { |filename, _| filename }, 17 | %r%^lib/sequel_core/(.*)\.rb$% => kernel.proc do |_, m| 18 | [ 19 | "spec/#{m[1]}_spec.rb", 20 | "spec/sequel/#{m[1]}_spec.rb" 21 | ] 22 | end, 23 | %r%^lib/sequel_model/(.*)\.rb$% => kernel.proc do |_, m| 24 | [ 25 | "spec/model/#{m[1]}_spec.rb"#, 26 | #"spec/sequel/#{m[1]}_spec.rb", 27 | #"spec/sequel/#{m[1]}_mixin_spec.rb" 28 | ] 29 | end, 30 | %r%^lib/sequel\.rb$% => kernel.proc { files_matching %r%^spec/.*_spec\.rb$% }, 31 | %r%^spec/(spec_helper|shared/.*)\.rb$% => kernel.proc { files_matching %r%^spec/.*_spec\.rb$% } 32 | } 33 | end 34 | 35 | def tests_for_file(filename) 36 | super.select { |f| @files.has_key? f } 37 | end 38 | 39 | alias :specs_for_file :tests_for_file 40 | 41 | def failed_results(results) 42 | results.scan(/^\d+\)\n(?:\e\[\d*m)?(?:.*?Error in )?'([^\n]*)'(?: FAILED)?(?:\e\[\d*m)?\n(.*?)\n\n/m) 43 | end 44 | 45 | def handle_results(results) 46 | @files_to_test = consolidate_failures failed_results(results) 47 | unless @files_to_test.empty? then 48 | hook :red 49 | else 50 | hook :green 51 | end unless $TESTING 52 | @tainted = true unless @files_to_test.empty? 53 | end 54 | 55 | def consolidate_failures(failed) 56 | filters = Hash.new { |h,k| h[k] = [] } 57 | failed.each do |spec, failed_trace| 58 | @files.keys.select{|f| f =~ /spec\//}.each do |f| 59 | if failed_trace =~ Regexp.new(f) 60 | filters[f] << spec 61 | break 62 | end 63 | end 64 | end 65 | return filters 66 | end 67 | 68 | def make_test_cmd(files_to_test) 69 | return "#{ruby} -S #{@spec_command} #{add_options_if_present} #{files_to_test.keys.flatten.join(' ')}" 70 | end 71 | 72 | def add_options_if_present 73 | File.exist?("spec/spec.opts") ? "-O spec/spec.opts " : "" 74 | end 75 | 76 | # Finds the proper spec command to use. Precendence 77 | # is set in the lazily-evaluated method spec_commands. Alias + Override 78 | # that in ~/.autotest to provide a different spec command 79 | # then the default paths provided. 80 | def spec_command 81 | spec_commands.each do |command| 82 | if File.exists?(command) 83 | return @alt_separator ? (command.gsub @separator, @alt_separator) : command 84 | end 85 | end 86 | 87 | raise RspecCommandError, "No spec command could be found!" 88 | end 89 | 90 | # Autotest will look for spec commands in the following 91 | # locations, in this order: 92 | # 93 | # * bin/spec 94 | # * default spec bin/loader installed in Rubygems 95 | def spec_commands 96 | [ 97 | File.join("bin", "spec"), 98 | File.join("usr","bin","spec"), 99 | File.join(Config::CONFIG["bindir"], "spec") 100 | ] 101 | end 102 | 103 | end 104 | -------------------------------------------------------------------------------- /lib/sequel_model/relationships/relationship.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | # Manages relationships between to models 4 | # 5 | # HasMany.new Post, :comments 6 | # HasOne.new Post, :author, :class => "User" 7 | # BelongsTo.new Comment, :post 8 | # @has_one = HasOne.new(Post, :author, :class => 'User').create 9 | class Relationship 10 | 11 | attr_reader :klass, :relation, :options, :join_table #, :arity 12 | 13 | def initialize(klass, relation, options = {}) 14 | @klass = klass 15 | @relation = relation 16 | @options = options 17 | # TODO: move the setup somewhere else: 18 | #setup options 19 | end 20 | 21 | def setup(options = {}) 22 | setup_join_table(options) 23 | define_relationship_accessor(options) 24 | end 25 | 26 | def setup_join_table(options = {}) 27 | @join_table = JoinTable.new(self.klass.table_name, relation.to_s.pluralize, options) 28 | @join_table.send((@join_table.exists? && options[:force] == true) ? :create_table! : :create_table) 29 | end 30 | 31 | def relation_class 32 | Inflector.constantize(options[:class] ||= Inflector.classify(@relation)) 33 | end 34 | 35 | def define_relationship_accessor(options = {}) 36 | if arity == :one 37 | klass.class_eval "def #{@relation} ; #{reader(options[:type])} ; end" 38 | else 39 | klass.class_eval "def #{@relation} ; #{reader(options[:type])} ; end" 40 | end 41 | end 42 | 43 | private 44 | 45 | def reader(type = nil) 46 | [:embeded, :foreign].include?(type) ? foreign_reader : join_reader 47 | end 48 | 49 | def foreign_reader 50 | "self.dataset.select(:#{relation.to_s.pluralize}.all)." << 51 | "join(:#{join_table.name}, :#{@klass.to_s.foreign_key} => :id)." << 52 | "join(:#{@relation.to_s.pluralize}, :id => :#{@relation.to_s.classify.foreign_key})." << 53 | "filter(:#{klass.to_s.tableize}__id => self.id)" 54 | end 55 | 56 | def join_reader 57 | # The 'general' idea: 58 | #"self.dataset.select(:#{relation.to_s.pluralize}.all)" << 59 | #"join(:#{join_table.name}, :#{table_name.to_s.singularize}_#{join_table.primary_key} => :#{primary_key})" << 60 | #"join(:#{relation.to_s.pluralize}, :#{relation.primary_key} => :#{relation.to_s.pluralize}_#{relation.primary_key})" << 61 | #"where(:#{table_name}__id => self.#{primary_key.to_s})" 62 | 63 | # TEMPORARY, getting the simple case working: 64 | "self.dataset.select(:#{relation.to_s.pluralize}.all)." << 65 | "join(:#{join_table.name}, :#{@klass.to_s.foreign_key} => :id)." << 66 | "join(:#{@relation.to_s.pluralize}, :id => :#{@relation.to_s.classify.foreign_key})." << 67 | "filter(:#{klass.to_s.tableize}__id => self.id)" 68 | end 69 | 70 | def writer(type = nil) 71 | [:embeded, :foreign].include?(type) ? foreign_writer : join_writer 72 | end 73 | 74 | # insert into foreign table 75 | # Post: has :one, :author 76 | # @post.author = @author 77 | # 78 | # Post: has :many, :comments 79 | # @post.comments << @comment 80 | def embeded_writer 81 | "@source" 82 | end 83 | 84 | # insert into join table 85 | # eg CommentPost.create(key1,key2) 86 | def join_writer 87 | "@join_table.create(@source.id,@destination.id)" 88 | end 89 | 90 | end 91 | 92 | 93 | end 94 | 95 | end 96 | -------------------------------------------------------------------------------- /lib/sequel_model/base.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | # Returns the database associated with the Model class. 4 | def self.db 5 | @db ||= (superclass != Object) && superclass.db or 6 | raise Error, "No database associated with #{self}" 7 | end 8 | 9 | # Sets the database associated with the Model class. 10 | def self.db=(db) 11 | @db = db 12 | end 13 | 14 | # Called when a database is opened in order to automatically associate the 15 | # first opened database with model classes. 16 | def self.database_opened(db) 17 | @db = db if (self == Model) && !@db 18 | end 19 | 20 | NAMESPACE_EXCLUSIVE_REGEXP = /([^:]+)$/.freeze 21 | 22 | # Returns the implicit table name for the model class. 23 | def self.implicit_table_name 24 | name[NAMESPACE_EXCLUSIVE_REGEXP].underscore.pluralize.to_sym 25 | end 26 | 27 | # Returns the dataset associated with the Model class. 28 | def self.dataset 29 | unless @dataset 30 | if ds = super_dataset 31 | set_dataset(ds.clone) 32 | elsif !name.empty? 33 | set_dataset(db[implicit_table_name]) 34 | else 35 | raise Error, "No dataset associated with #{self}" 36 | end 37 | end 38 | @dataset 39 | end 40 | 41 | # def self.dataset 42 | # @dataset ||= super_dataset || 43 | # (!(n = name).empty? && db[n.underscore.pluralize.to_sym]) || 44 | # (raise Error, "No dataset associated with #{self}") 45 | # end 46 | 47 | def self.super_dataset # :nodoc: 48 | superclass.dataset if (superclass != Sequel::Model) && superclass.respond_to?(:dataset) 49 | end 50 | 51 | # Returns the columns in the result set in their original order. 52 | # 53 | # See Dataset#columns for more information. 54 | def self.columns 55 | @columns ||= dataset.columns or 56 | raise Error, "Could not fetch columns for #{self}" 57 | end 58 | 59 | # Sets the dataset associated with the Model class. 60 | def self.set_dataset(ds) 61 | @db = ds.db 62 | @dataset = ds 63 | @dataset.set_model(self) 64 | @dataset.transform(@transform) if @transform 65 | end 66 | 67 | # Returns the database assoiated with the object's Model class. 68 | def db 69 | @db ||= model.db 70 | end 71 | 72 | # Returns the dataset assoiated with the object's Model class. 73 | # 74 | # See Dataset for more information. 75 | def dataset 76 | model.dataset 77 | end 78 | 79 | # Returns the columns associated with the object's Model class. 80 | def columns 81 | model.columns 82 | end 83 | 84 | # Serializes column with YAML or through marshalling. 85 | def self.serialize(*columns) 86 | format = columns.pop[:format] if Hash === columns.last 87 | format ||= :yaml 88 | 89 | @transform = columns.inject({}) do |m, c| 90 | m[c] = format 91 | m 92 | end 93 | @dataset.transform(@transform) if @dataset 94 | end 95 | end 96 | 97 | # Lets you create a Model class with its table name already set or reopen 98 | # an existing Model. 99 | # 100 | # Makes given dataset inherited. 101 | # 102 | # === Example: 103 | # class Comment < Sequel::Model(:something) 104 | # table_name # => :something 105 | # 106 | # # ... 107 | # 108 | # end 109 | def self.Model(source) 110 | @models ||= {} 111 | @models[source] ||= Class.new(Sequel::Model) do 112 | meta_def(:inherited) do |c| 113 | c.set_dataset(source.is_a?(Dataset) ? source : c.db[source]) 114 | end 115 | end 116 | end 117 | 118 | end -------------------------------------------------------------------------------- /spec/relations_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | describe Sequel::Model, "one_to_one" do 4 | 5 | before(:each) do 6 | MODEL_DB.reset 7 | 8 | @c1 = Class.new(Sequel::Model(:attributes)) do 9 | end 10 | 11 | @c2 = Class.new(Sequel::Model(:nodes)) do 12 | end 13 | 14 | @dataset = @c2.dataset 15 | 16 | $sqls = [] 17 | @dataset.extend(Module.new { 18 | def fetch_rows(sql) 19 | $sqls << sql 20 | yield({:hey => 1}) 21 | end 22 | 23 | def update(values) 24 | $sqls << update_sql(values) 25 | end 26 | } 27 | ) 28 | end 29 | 30 | it "should use implicit key if omitted" do 31 | @c2.one_to_one :parent, :from => @c2 32 | 33 | d = @c2.new(:id => 1, :parent_id => 234) 34 | p = d.parent 35 | p.class.should == @c2 36 | p.values.should == {:hey => 1} 37 | 38 | $sqls.should == ["SELECT * FROM nodes WHERE (id = 234) LIMIT 1"] 39 | end 40 | 41 | it "should use explicit key if given" do 42 | @c2.one_to_one :parent, :from => @c2, :key => :blah 43 | 44 | d = @c2.new(:id => 1, :blah => 567) 45 | p = d.parent 46 | p.class.should == @c2 47 | p.values.should == {:hey => 1} 48 | 49 | $sqls.should == ["SELECT * FROM nodes WHERE (id = 567) LIMIT 1"] 50 | end 51 | 52 | it "should support plain dataset in the from option" do 53 | @c2.one_to_one :parent, :from => MODEL_DB[:xyz] 54 | 55 | d = @c2.new(:id => 1, :parent_id => 789) 56 | p = d.parent 57 | p.class.should == Hash 58 | 59 | MODEL_DB.sqls.should == ["SELECT * FROM xyz WHERE (id = 789) LIMIT 1"] 60 | end 61 | 62 | it "should support table name in the from option" do 63 | @c2.one_to_one :parent, :from => :abc 64 | 65 | d = @c2.new(:id => 1, :parent_id => 789) 66 | p = d.parent 67 | p.class.should == Hash 68 | 69 | MODEL_DB.sqls.should == ["SELECT * FROM abc WHERE (id = 789) LIMIT 1"] 70 | end 71 | 72 | it "should return nil if key value is nil" do 73 | @c2.one_to_one :parent, :from => @c2 74 | 75 | d = @c2.new(:id => 1) 76 | d.parent.should == nil 77 | end 78 | 79 | it "should define a setter method" do 80 | @c2.one_to_one :parent, :from => @c2 81 | 82 | d = @c2.new(:id => 1) 83 | d.parent = {:id => 4321} 84 | d.values.should == {:id => 1, :parent_id => 4321} 85 | $sqls.last.should == "UPDATE nodes SET parent_id = 4321 WHERE (id = 1)" 86 | 87 | d.parent = nil 88 | d.values.should == {:id => 1, :parent_id => nil} 89 | $sqls.last.should == "UPDATE nodes SET parent_id = NULL WHERE (id = 1)" 90 | 91 | e = @c2.new(:id => 6677) 92 | d.parent = e 93 | d.values.should == {:id => 1, :parent_id => 6677} 94 | $sqls.last.should == "UPDATE nodes SET parent_id = 6677 WHERE (id = 1)" 95 | end 96 | end 97 | 98 | describe Sequel::Model, "one_to_many" do 99 | 100 | before(:each) do 101 | MODEL_DB.reset 102 | 103 | @c1 = Class.new(Sequel::Model(:attributes)) do 104 | end 105 | 106 | @c2 = Class.new(Sequel::Model(:nodes)) do 107 | end 108 | end 109 | 110 | it "should define a getter method" do 111 | @c2.one_to_many :attributes, :from => @c1, :key => :node_id 112 | 113 | n = @c2.new(:id => 1234) 114 | a = n.attributes 115 | a.should be_a_kind_of(Sequel::Dataset) 116 | a.sql.should == 'SELECT * FROM attributes WHERE (node_id = 1234)' 117 | end 118 | 119 | it "should support plain dataset in the from option" do 120 | @c2.one_to_many :attributes, :from => MODEL_DB[:xyz], :key => :node_id 121 | 122 | n = @c2.new(:id => 1234) 123 | a = n.attributes 124 | a.should be_a_kind_of(Sequel::Dataset) 125 | a.sql.should == 'SELECT * FROM xyz WHERE (node_id = 1234)' 126 | end 127 | 128 | it "should support table name in the from option" do 129 | @c2.one_to_many :attributes, :from => :abc, :key => :node_id 130 | 131 | n = @c2.new(:id => 1234) 132 | a = n.attributes 133 | a.should be_a_kind_of(Sequel::Dataset) 134 | a.sql.should == 'SELECT * FROM abc WHERE (node_id = 1234)' 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /spec/relationships/abstract_relationship_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "../spec_helper") 2 | 3 | describe Sequel::Model::AbstractRelationship do 4 | 5 | describe "intance methods" do 6 | 7 | before :each do 8 | class Post < Sequel::Model(:posts); end 9 | class People < Sequel::Model(:people); end 10 | class Comment < Sequel::Model(:comments); end 11 | @one = Sequel::Model::HasOneRelationship.new Post, :author, {:class => "People"} 12 | @many = Sequel::Model::HasManyRelationship.new Post, :comments, {:force => true} 13 | @belong = Sequel::Model::BelongsToRelationship.new Post, :author, {:class => "People"} 14 | @join_table = mock(Sequel::Model::JoinTable) 15 | end 16 | 17 | describe "create" do 18 | it "should call the create join table method" do 19 | @one.should_receive(:create_join_table).and_return(true) 20 | @one.should_receive(:define_relationship_accessor) 21 | @one.create 22 | end 23 | end 24 | 25 | describe "create_join_table" do 26 | before :each do 27 | @one.stub!(:define_accessor) 28 | @many.stub!(:define_accessor) 29 | end 30 | 31 | it "should create the table if it doesn't exist" do 32 | Post.should_receive(:table_name).and_return('posts') 33 | Sequel::Model::JoinTable.should_receive(:new).with('posts', 'authors').and_return(@join_table) 34 | @join_table.should_receive(:exists?).and_return(false) 35 | @join_table.should_receive(:create) 36 | @one.create_join_table 37 | @one.join_table.should == @join_table 38 | end 39 | 40 | it "should force create the table when the option is specified" do 41 | Post.should_receive(:table_name).and_return('posts') 42 | Sequel::Model::JoinTable.should_receive(:new).with('posts', 'comments').and_return(@join_table) 43 | @join_table.should_receive(:exists?).and_return(true) 44 | @join_table.should_receive(:create!) 45 | @many.create_join_table 46 | @many.join_table.should == @join_table 47 | end 48 | end 49 | 50 | describe "define_relationship_accessor" do 51 | 52 | describe "reader" do 53 | 54 | it "should return a dataset for a has :one relationship" do 55 | @one.stub!(:create_table) 56 | @one.should_receive(:join_table).and_return(@join_table) 57 | @join_table.should_receive(:name).and_return(:authors_posts) 58 | @one.define_relationship_accessor 59 | @post = Post.new(:id => 1) 60 | @post.author.sql.should == "SELECT authors.* FROM posts INNER JOIN authors_posts ON (authors_posts.post_id = posts.id) INNER JOIN authors ON (authors.id = authors_posts.author_id) WHERE (posts.id = #{@post.id})" 61 | end 62 | 63 | it "should return a dataset for a has :many relationship" do 64 | @many.should_receive(:join_table).and_return(@join_table) 65 | @join_table.should_receive(:name).and_return(:posts_comments) 66 | @many.define_relationship_accessor 67 | @post = Post.new(:id => 1) 68 | @post.comments.sql.should == "SELECT comments.* FROM posts INNER JOIN posts_comments ON (posts_comments.post_id = posts.id) INNER JOIN comments ON (comments.id = posts_comments.comment_id) WHERE (posts.id = #{@post.id})" 69 | end 70 | 71 | end 72 | 73 | describe "writer" do 74 | 75 | it "should define a writer method 'relation=' for the relation model when the relationship is has :one" do 76 | 77 | end 78 | 79 | it "should define the append method '<<' for the relation model when the relationship is has :many" do 80 | 81 | end 82 | 83 | end 84 | 85 | end 86 | 87 | describe "arity" do 88 | it "should return :one when an instance of HasOneRelationship" do 89 | @one.arity.should == :one 90 | end 91 | 92 | it "should return :many when an instance of HasManyRelationship" do 93 | @many.arity.should == :many 94 | end 95 | 96 | it "should return :one when an instance of BelongsToRelationship" do 97 | @belong.arity.should == :one 98 | end 99 | end 100 | 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/sequel_model/relationships.rb: -------------------------------------------------------------------------------- 1 | files = %w{ scoping relationship has_one has_many block join_table } 2 | dir = File.join(File.dirname(__FILE__), "relationships") 3 | files.each {|f| require(File.join(dir, f))} 4 | 5 | # = Sequel Relationships 6 | # Database modelling is generally done with an ER (Entity Relationship) diagram. 7 | # Shouldn't ORM's facilitate simlilar specification? 8 | 9 | # class Post < Sequel::Model 10 | # relationships do 11 | # # Specify the relationships that exist with the User model (users table) 12 | # # These relationships are precisely the ER diagram connecting arrows. 13 | # end 14 | # end 15 | 16 | # 17 | # = Relationships 18 | # 19 | # are specifications of the ends of the ER diagrams connectors that are touching 20 | # the current model. 21 | # 22 | # one_to_one, has_one 23 | # many_to_one, belongs_to 24 | # many_to_many, has_many 25 | 26 | # ?parameters may be :zero, :one, :many which specifies the cardinality of the connection 27 | 28 | # Example: 29 | # class Post < Sequel::Model 30 | # relationships do 31 | # has :one, :blog, :required => true, :normalized => false # uses a blog_id field, which cannot be null, in the Post model 32 | # has :one, :account # uses a join table called accounts_posts to link the post with it's account. 33 | # has :many, :comments # uses a comments_posts join table 34 | # has :many, :authors, :required => true # authors_posts join table, requires at least one author 35 | # end 36 | # end 37 | # 38 | # 39 | # Relationship API Details 40 | # 41 | 42 | # 43 | # == belongs_to 44 | # 45 | 46 | # Defines an blog and blog= method 47 | # belongs_to :blog 48 | 49 | # Same, but uses "b_id" as the blog's id field. 50 | # belongs_to :blog, :key => :b_id 51 | 52 | # has_many :comments 53 | # * Defines comments method which will query the join table appropriately. 54 | # * Checks to see if a "comments_posts" join table exists (alphabetical order) 55 | # ** If it does not exist, will create the join table. 56 | # ** If options are passed in these will be used to further define the join table. 57 | 58 | 59 | # Benefits: 60 | # * Normalized DB 61 | # * Easy to define join objects 62 | # * Efficient queries, database gets to use indexed fields (pkeys) instead of a string field and an id. 63 | # 64 | # For example, polymorphic associations now become: 65 | # [user] 1-* [addresses_users] *-1 [addresses] 66 | # [companies] 1-* [addresses_companies] *-1 [addresses] 67 | # [clients] 1-* [addresses_clients] *-1 [addresses] 68 | # it is automatically polymorphic by specifying the has relationship inside the 2User and Company tables to addresses. Addresses themselves don't care. so we have by default polymorphism. 69 | # If you need to talk about a 'Company Address' then you can subclass, CompanyAddress < Address and do has :many, :company_addresses 70 | 71 | module Sequel 72 | 73 | class Model 74 | 75 | class << self 76 | @relationships = [] 77 | 78 | # has arity, model 79 | # has :one, :blog, :required => true # blog_id field, cannot be null 80 | # has :one, :account # account_id field 81 | # has :many, :comments # comments_posts join table 82 | # has :many, :comment # comments_posts join table 83 | def has(arity, relation, options = {}) 84 | # Create and store the relationship 85 | case arity 86 | when :one 87 | @relationships << HasOne.new(self, relation, options) 88 | when :many 89 | @relationships << HasMany.new(self, relation, options) 90 | else 91 | raise Sequel::Error, "Arity must be specified {:one, :many}." 92 | end 93 | 94 | #unless normalized 95 | # :required => true # The relationship must be populated to save 96 | # can only be used with normalized => false : 97 | #end 98 | # save the relationship 99 | end 100 | 101 | # the proxy methods has_xxx ... , simply pass thru to to has :xxx, ... 102 | def has_one(relation, options = {}) 103 | has :one, relation, options 104 | end 105 | 106 | def has_many(relation, options = {}) 107 | has :many, relation, options 108 | end 109 | 110 | def belongs_to(relation, options = {}) 111 | @relationships << BelongsTo.new(self, relation, options) 112 | end 113 | 114 | #def primary_key_string 115 | # "#{self.to_s.tableize.singularize}_id" 116 | #end 117 | 118 | end 119 | 120 | end # Model 121 | 122 | end # Sequel 123 | -------------------------------------------------------------------------------- /spec/caching_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | describe Sequel::Model, "caching" do 4 | 5 | before(:each) do 6 | MODEL_DB.reset 7 | 8 | @cache_class = Class.new(Hash) do 9 | attr_accessor :ttl 10 | def set(k, v, ttl); self[k] = v; @ttl = ttl; end 11 | def get(k); self[k]; end 12 | end 13 | cache = @cache_class.new 14 | @cache = cache 15 | 16 | @c = Class.new(Sequel::Model(:items)) do 17 | set_cache cache 18 | 19 | def self.columns 20 | [:name, :id] 21 | end 22 | end 23 | 24 | $cache_dataset_row = {:name => 'sharon', :id => 1} 25 | @dataset = @c.dataset 26 | $sqls = [] 27 | @dataset.extend(Module.new { 28 | def fetch_rows(sql) 29 | $sqls << sql 30 | yield $cache_dataset_row 31 | end 32 | 33 | def update(values) 34 | $sqls << update_sql(values) 35 | $cache_dataset_row.merge!(values) 36 | end 37 | 38 | def delete 39 | $sqls << delete_sql 40 | end 41 | }) 42 | end 43 | 44 | it "should set the model's cache store" do 45 | @c.cache_store.should be(@cache) 46 | end 47 | 48 | it "should have a default ttl of 3600" do 49 | @c.cache_ttl.should == 3600 50 | end 51 | 52 | it "should take a ttl option" do 53 | @c.set_cache @cache, :ttl => 1234 54 | @c.cache_ttl.should == 1234 55 | end 56 | 57 | it "should offer a set_cache_ttl method for setting the ttl" do 58 | @c.cache_ttl.should == 3600 59 | @c.set_cache_ttl 1234 60 | @c.cache_ttl.should == 1234 61 | end 62 | 63 | it "should generate a cache key appropriate to the class" do 64 | m = @c.new 65 | m.values[:id] = 1 66 | m.cache_key.should == "#{m.class}:1" 67 | 68 | # custom primary key 69 | @c.set_primary_key :ttt 70 | m = @c.new 71 | m.values[:ttt] = 333 72 | m.cache_key.should == "#{m.class}:333" 73 | 74 | # composite primary key 75 | @c.set_primary_key [:a, :b, :c] 76 | m = @c.new 77 | m.values[:a] = 123 78 | m.values[:c] = 456 79 | m.values[:b] = 789 80 | m.cache_key.should == "#{m.class}:123,789,456" 81 | end 82 | 83 | it "should raise error if attempting to generate cache_key and primary key value is null" do 84 | m = @c.new 85 | proc {m.cache_key}.should raise_error(Sequel::Error) 86 | 87 | m.values[:id] = 1 88 | proc {m.cache_key}.should_not raise_error(Sequel::Error) 89 | end 90 | 91 | it "should set the cache when reading from the database" do 92 | $sqls.should == [] 93 | @cache.should be_empty 94 | 95 | m = @c[1] 96 | $sqls.should == ['SELECT * FROM items WHERE (id = 1) LIMIT 1'] 97 | m.values.should == $cache_dataset_row 98 | @cache[m.cache_key].should == m 99 | 100 | # read from cache 101 | m2 = @c[1] 102 | $sqls.should == ['SELECT * FROM items WHERE (id = 1) LIMIT 1'] 103 | m2.should == m 104 | m2.values.should == $cache_dataset_row 105 | end 106 | 107 | it "should delete the cache when writing to the database" do 108 | # fill the cache 109 | m = @c[1] 110 | @cache[m.cache_key].should == m 111 | 112 | m.set(:name => 'tutu') 113 | @cache.has_key?(m.cache_key).should be_false 114 | $sqls.last.should == "UPDATE items SET name = 'tutu' WHERE (id = 1)" 115 | 116 | m = @c[1] 117 | @cache[m.cache_key].should == m 118 | m.name = 'hey' 119 | m.save 120 | @cache.has_key?(m.cache_key).should be_false 121 | $sqls.last.should == "UPDATE items SET name = 'hey', id = 1 WHERE (id = 1)" 122 | end 123 | 124 | it "should delete the cache when deleting the record" do 125 | # fill the cache 126 | m = @c[1] 127 | @cache[m.cache_key].should == m 128 | 129 | m.delete 130 | @cache.has_key?(m.cache_key).should be_false 131 | $sqls.last.should == "DELETE FROM items WHERE (id = 1)" 132 | end 133 | 134 | it "should support #[] as a shortcut to #find with hash" do 135 | m = @c[:id => 3] 136 | @cache[m.cache_key].should be_nil 137 | $sqls.last.should == "SELECT * FROM items WHERE (id = 3) LIMIT 1" 138 | 139 | m = @c[1] 140 | @cache[m.cache_key].should == m 141 | $sqls.should == ["SELECT * FROM items WHERE (id = 3) LIMIT 1", \ 142 | "SELECT * FROM items WHERE (id = 1) LIMIT 1"] 143 | 144 | @c[:id => 4] 145 | $sqls.should == ["SELECT * FROM items WHERE (id = 3) LIMIT 1", \ 146 | "SELECT * FROM items WHERE (id = 1) LIMIT 1", \ 147 | "SELECT * FROM items WHERE (id = 4) LIMIT 1"] 148 | end 149 | 150 | end 151 | -------------------------------------------------------------------------------- /spec/relationships/join_table_spec.rb: -------------------------------------------------------------------------------- 1 | __END__ 2 | 3 | 4 | describe Sequel::Model::JoinTable do 5 | 6 | describe "class methods" do 7 | 8 | before(:all) do 9 | class Person < Sequel::Model 10 | set_primary_key [:first_name, :last_name, :middle_name] 11 | end 12 | class Address < Sequel::Model 13 | set_primary_key [:street,:suite,:zip] 14 | end 15 | class Monkey < Sequel::Model 16 | # primary key should be :id 17 | end 18 | end 19 | 20 | describe "keys" do 21 | 22 | it "should return an array of the primary keys for a complex primary key" do 23 | # @join_table = Sequel::Model::JoinTable.new :person, :address 24 | Sequel::Model::JoinTable.keys(Person).should eql(["person_first_name", "person_last_name", "person_middle_name"]) 25 | Sequel::Model::JoinTable.keys(Address).should eql(["address_street", "address_suite", "address_zip"]) 26 | Sequel::Model::JoinTable.keys(Monkey).should eql(["monkey_id"]) 27 | end 28 | 29 | end 30 | 31 | end 32 | 33 | describe "instance methods" do 34 | 35 | before(:each) do 36 | class Post < Sequel::Model(:posts); end 37 | class Comment < Sequel::Model(:comments); end 38 | class Article < Sequel::Model(:articles); end 39 | @join_table = Sequel::Model::JoinTable.new :post, :comment 40 | @join_table_plural = Sequel::Model::JoinTable.new :posts, :comments 41 | @join_table_string = Sequel::Model::JoinTable.new "posts", "comments" 42 | @db = mock("db instance") 43 | end 44 | 45 | describe "name" do 46 | 47 | it "should have a proper join table name" do 48 | @join_table.name.should == "comments_posts" 49 | @join_table_plural.name.should == "comments_posts" 50 | @join_table_string.name.should == "comments_posts" 51 | end 52 | 53 | end 54 | 55 | describe "join class" do 56 | 57 | it "should define the join class if it does not exist" do 58 | class Foo < Sequel::Model(:foos); end 59 | class Bar < Sequel::Model(:bars); end 60 | Sequel::Model::JoinTable.new :foos, :bars 61 | defined?(FooBar).should_not be_nil 62 | end 63 | 64 | it "should not redefine the join class if it already exists" do 65 | undef ArticleComment if defined?(ArticleComment) 66 | class ArticleComment < Sequel::Model 67 | set_primary_key :id 68 | end 69 | @join_table = Sequel::Model::JoinTable.new :article, :comment 70 | ArticleComment.primary_key.should == :id 71 | end 72 | 73 | it "should return the join class" do 74 | @join_table.join_class.should eql(PostComment) 75 | end 76 | 77 | end 78 | 79 | describe "exists?" do 80 | 81 | before :each do 82 | @join_table.should_receive(:db).and_return(@db) 83 | @db.should_receive(:[]).with("comments_posts").and_return(@db) 84 | end 85 | 86 | it "should indicate if the table exists" do 87 | @db.should_receive(:table_exists?).and_return(true) 88 | @join_table.exists?.should == true 89 | end 90 | 91 | it "should indicate if the table does not exist" do 92 | @db.should_receive(:table_exists?).and_return(false) 93 | @join_table.exists?.should == false 94 | end 95 | 96 | end 97 | 98 | describe "create" do 99 | 100 | it "should create the table if it doesn't exist" do 101 | @join_table.should_receive(:exists?).and_return(false) 102 | @join_table.should_receive(:db).and_return(@db) 103 | @db.should_receive(:create_table).with(:comments_posts) 104 | @join_table.create.should be_true 105 | end 106 | 107 | it "should fail to create the table if it does exist" do 108 | @join_table.should_receive(:exists?).and_return(true) 109 | @join_table.create.should be_false 110 | end 111 | 112 | end 113 | 114 | describe "create!" do 115 | 116 | it "should force the creation of the table it exists" do 117 | @join_table.should_receive(:exists?).and_return(true) 118 | @join_table.should_receive(:db).and_return(@db) 119 | @db.should_receive(:drop_table).with("comments_posts") 120 | @join_table.should_receive(:create).and_return(true) 121 | @join_table.create!.should be_true 122 | end 123 | 124 | end 125 | 126 | describe "db" do 127 | 128 | it "should have access to the db object" do 129 | class Post; end 130 | 131 | Post.should_receive(:db).and_return(@db) 132 | @join_table.db.should == @db 133 | end 134 | 135 | end 136 | 137 | end 138 | 139 | end 140 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake" 2 | require "rake/clean" 3 | require "rake/gempackagetask" 4 | require "rake/rdoctask" 5 | require "fileutils" 6 | include FileUtils 7 | 8 | ############################################################################## 9 | # Configuration 10 | ############################################################################## 11 | NAME = "sequel_model" 12 | VERS = "0.4.1" 13 | CLEAN.include ["**/.*.sw?", "pkg/*", ".config", "doc/*", "coverage/*"] 14 | RDOC_OPTS = [ 15 | "--quiet", 16 | "--title", "Sequel Model: Lightweight ORM for Ruby", 17 | "--opname", "index.html", 18 | "--line-numbers", 19 | "--main", "README", 20 | "--inline-source" 21 | ] 22 | 23 | ############################################################################## 24 | # RDoc 25 | ############################################################################## 26 | task :doc => [:rdoc] 27 | 28 | Rake::RDocTask.new do |rdoc| 29 | rdoc.rdoc_dir = "doc/rdoc" 30 | rdoc.options += RDOC_OPTS 31 | rdoc.main = "README" 32 | rdoc.title = "Sequel: Lightweight ORM for Ruby" 33 | rdoc.rdoc_files.add ["README", "COPYING", "lib/sequel_model.rb", "lib/**/*.rb"] 34 | end 35 | 36 | ############################################################################## 37 | # Gem packaging 38 | ############################################################################## 39 | desc "Packages up Sequel." 40 | task :default => [:package] 41 | task :package => [:clean] 42 | 43 | spec = Gem::Specification.new do |s| 44 | s.name = NAME 45 | s.rubyforge_project = 'sequel' 46 | s.version = VERS 47 | s.platform = Gem::Platform::RUBY 48 | s.has_rdoc = true 49 | s.extra_rdoc_files = ["README", "CHANGELOG", "COPYING"] 50 | s.rdoc_options += RDOC_OPTS + 51 | ["--exclude", "^(examples|extras)\/", "--exclude", "lib/sequel_model.rb"] 52 | s.summary = "Lightweight ORM for Ruby" 53 | s.description = s.summary 54 | s.author = "Sharon Rosner" 55 | s.email = "ciconia@gmail.com" 56 | s.homepage = "http://sequel.rubyforge.org" 57 | s.required_ruby_version = ">= 1.8.4" 58 | 59 | case RUBY_PLATFORM 60 | when /java/ 61 | s.platform = "jruby" 62 | else 63 | s.platform = Gem::Platform::RUBY 64 | end 65 | 66 | s.add_dependency("assistance", '>= 0.1.2') 67 | s.add_dependency("sequel_core", '>= 1.0') 68 | 69 | s.files = %w(COPYING README Rakefile) + Dir.glob("{doc,spec,lib}/**/*") 70 | 71 | s.require_path = "lib" 72 | end 73 | 74 | Rake::GemPackageTask.new(spec) do |p| 75 | p.need_tar = true 76 | p.gem_spec = spec 77 | end 78 | 79 | ############################################################################## 80 | # installation & removal 81 | ############################################################################## 82 | task :install do 83 | sh %{rake package} 84 | sh %{sudo gem install pkg/#{NAME}-#{VERS}} 85 | end 86 | 87 | task :install_no_docs do 88 | sh %{rake package} 89 | sh %{sudo gem install pkg/#{NAME}-#{VERS} --no-rdoc --no-ri} 90 | end 91 | 92 | task :uninstall => [:clean] do 93 | sh %{sudo gem uninstall #{NAME}} 94 | end 95 | 96 | task :tag do 97 | cwd = FileUtils.pwd 98 | sh %{rm -rf doc/*} 99 | sh %{rm -rf pkg/*} 100 | sh %{rm -rf coverage/*} 101 | sh %{cd ../.. && svn copy #{cwd} tags/#{NAME}-#{VERS} && svn commit -m "#{NAME}-#{VERS} tag." tags} 102 | end 103 | 104 | ############################################################################## 105 | # gem and rdoc release 106 | ############################################################################## 107 | task :release => [:package] do 108 | sh %{rubyforge login} 109 | sh %{rubyforge add_release sequel #{NAME} #{VERS} pkg/#{NAME}-#{VERS}.tgz} 110 | sh %{rubyforge add_file sequel #{NAME} #{VERS} pkg/#{NAME}-#{VERS}.gem} 111 | end 112 | 113 | ############################################################################## 114 | # specs 115 | ############################################################################## 116 | require "spec/rake/spectask" 117 | 118 | desc "Run specs with coverage" 119 | Spec::Rake::SpecTask.new("spec") do |t| 120 | t.spec_files = FileList["spec/**/*_spec.rb"] 121 | t.spec_opts = File.read("spec/spec.opts").split("\n") 122 | t.rcov_opts = File.read("spec/rcov.opts").split("\n") 123 | t.rcov = true 124 | end 125 | 126 | desc "Run specs without coverage" 127 | Spec::Rake::SpecTask.new("spec_no_cov") do |t| 128 | t.spec_files = FileList["spec/**/*_spec.rb"] 129 | t.spec_opts = File.read("spec/spec.opts").split("\n") 130 | end 131 | 132 | desc "check documentation coverage" 133 | task :dcov do 134 | sh "find lib -name '*.rb' | xargs dcov" 135 | end 136 | 137 | ############################################################################## 138 | # Statistics 139 | ############################################################################## 140 | 141 | STATS_DIRECTORIES = [ 142 | %w(Code lib/), 143 | %w(Spec spec/) 144 | ].collect { |name, dir| [ name, "./#{dir}" ] }.select { |name, dir| File.directory?(dir) } 145 | 146 | desc "Report code statistics (KLOCs, etc) from the application" 147 | task :stats do 148 | require "extra/stats" 149 | verbose = true 150 | CodeStatistics.new(*STATS_DIRECTORIES).to_s 151 | end 152 | 153 | -------------------------------------------------------------------------------- /lib/sequel_model/relations.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | ID_POSTFIX = '_id'.freeze 4 | 5 | # Creates a 1-1 relationship by defining an association method, e.g.: 6 | # 7 | # class Session < Sequel::Model 8 | # end 9 | # 10 | # class Node < Sequel::Model 11 | # one_to_one :producer, :from => Session 12 | # # which is equivalent to 13 | # def producer 14 | # Session[producer_id] if producer_id 15 | # end 16 | # end 17 | # 18 | # You can also set the foreign key explicitly by including a :key option: 19 | # 20 | # one_to_one :producer, :from => Session, :key => :producer_id 21 | # 22 | # The one_to_one macro also creates a setter, which accepts nil, a hash or 23 | # a model instance, e.g.: 24 | # 25 | # p = Producer[1234] 26 | # node = Node[:path => '/'] 27 | # node.producer = p 28 | # node.producer_id #=> 1234 29 | # 30 | def self.one_to_one(name, opts) 31 | from = opts[:from] 32 | from || (raise Error, "No association source defined (use :from option)") 33 | key = opts[:key] || (name.to_s + ID_POSTFIX).to_sym 34 | 35 | setter_name = "#{name}=".to_sym 36 | 37 | case from 38 | when Symbol 39 | class_def(name) {(k = @values[key]) ? db[from][:id => k] : nil} 40 | when Sequel::Dataset 41 | class_def(name) {(k = @values[key]) ? from[:id => k] : nil} 42 | else 43 | class_def(name) {(k = @values[key]) ? from[k] : nil} 44 | end 45 | class_def(setter_name) do |v| 46 | case v 47 | when nil 48 | set(key => nil) 49 | when Sequel::Model 50 | set(key => v.pk) 51 | when Hash 52 | set(key => v[:id]) 53 | end 54 | end 55 | 56 | # define_method name, &eval(ONE_TO_ONE_PROC % [key, from]) 57 | end 58 | 59 | # Creates a 1-N relationship by defining an association method, e.g.: 60 | # 61 | # class Book < Sequel::Model 62 | # end 63 | # 64 | # class Author < Sequel::Model 65 | # one_to_many :books, :from => Book 66 | # # which is equivalent to 67 | # def books 68 | # Book.filter(:author_id => id) 69 | # end 70 | # end 71 | # 72 | # You can also set the foreign key explicitly by including a :key option: 73 | # 74 | # one_to_many :books, :from => Book, :key => :author_id 75 | # 76 | def self.one_to_many(name, opts) 77 | from = opts[:from] 78 | from || (raise Error, "No association source defined (use :from option)") 79 | key = opts[:key] || (self.to_s + ID_POSTFIX).to_sym 80 | 81 | case from 82 | when Symbol 83 | class_def(name) {db[from].filter(key => pk)} 84 | else 85 | class_def(name) {from.filter(key => pk)} 86 | end 87 | end 88 | 89 | # TODO: Add/Replace current relations with the following specifications: 90 | # ====================================================================== 91 | 92 | # Database modelling is generally done with an ER (Entity Relationship) diagram. 93 | # Shouldn't ORM's facilitate simlilar specification? 94 | 95 | # class Post < Sequel::Model 96 | # relationships do 97 | # # Specify the relationships that exist with the User model (users table) 98 | # # These relationships are precisely the ER diagram connecting arrows. 99 | # end 100 | # end 101 | 102 | # 103 | # = Relationships 104 | # 105 | # are specifications of the ends of the ER diagrams connectors that are touching 106 | # the current model. 107 | # 108 | # one_to_one, has_one 109 | # many_to_one, belongs_to 110 | # many_to_many, has_many 111 | # ?parameters may be :zero, :one, :many which specifies the cardinality of the connection 112 | 113 | # Example: 114 | # class Post < Sequel::Model 115 | # relationships do 116 | # has :one, :blog, :required => true # blog_id field, cannot be null 117 | # has :one, :account # account_id field 118 | # has :many, :comments # comments_posts join table 119 | # has :many, :authors, :required => true # authors_posts join table, requires at least one author 120 | # end 121 | # end 122 | 123 | # 124 | # Relationship API Details 125 | # 126 | 127 | # 128 | # == belongs_to 129 | # 130 | 131 | # Defines an blog and blog= method 132 | # belongs_to :blog 133 | 134 | # Same, but uses "b_id" as the blog's id field. 135 | # belongs_to :blog, :key => :b_id 136 | 137 | # has_many :comments 138 | # * Defines comments method which will query the join table appropriately. 139 | # * Checks to see if a "comments_posts" join table exists (alphabetical order) 140 | # ** If it does not exist, will create the join table. 141 | # ** If options are passed in these will be used to further define the join table. 142 | 143 | 144 | # Benefits: 145 | # * Normalized DB 146 | # * Easy to define join objects 147 | # * Efficient queries, database gets to use indexed fields (pkeys) instead of a string field and an id. 148 | # 149 | # For example, polymorphic associations now become: 150 | # [user] 1-* [addresses_users] *-1 [addresses] 151 | # [companies] 1-* [addresses_companies] *-1 [addresses] 152 | # [clients] 1-* [addresses_clients] *-1 [addresses] 153 | end 154 | end -------------------------------------------------------------------------------- /spec/base_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | describe "Model attribute setters" do 4 | 5 | before(:each) do 6 | MODEL_DB.reset 7 | 8 | @c = Class.new(Sequel::Model(:items)) do 9 | def columns 10 | [:id, :x, :y] 11 | end 12 | end 13 | end 14 | 15 | it "should mark the column value as changed" do 16 | o = @c.new 17 | o.changed_columns.should == [] 18 | 19 | o.x = 2 20 | o.changed_columns.should == [:x] 21 | 22 | o.y = 3 23 | o.changed_columns.should == [:x, :y] 24 | 25 | o.changed_columns.clear 26 | 27 | o[:x] = 2 28 | o.changed_columns.should == [:x] 29 | 30 | o[:y] = 3 31 | o.changed_columns.should == [:x, :y] 32 | end 33 | 34 | end 35 | 36 | describe "Model#serialize" do 37 | 38 | before(:each) do 39 | MODEL_DB.reset 40 | end 41 | 42 | it "should translate values to YAML when creating records" do 43 | @c = Class.new(Sequel::Model(:items)) do 44 | no_primary_key 45 | serialize :abc 46 | end 47 | 48 | @c.create(:abc => 1) 49 | @c.create(:abc => "hello") 50 | 51 | MODEL_DB.sqls.should == [ \ 52 | "INSERT INTO items (abc) VALUES ('--- 1\n')", \ 53 | "INSERT INTO items (abc) VALUES ('--- hello\n')", \ 54 | ] 55 | end 56 | 57 | it "should support calling after the class is defined" do 58 | @c = Class.new(Sequel::Model(:items)) do 59 | no_primary_key 60 | end 61 | 62 | @c.serialize :def 63 | 64 | @c.create(:def => 1) 65 | @c.create(:def => "hello") 66 | 67 | MODEL_DB.sqls.should == [ \ 68 | "INSERT INTO items (def) VALUES ('--- 1\n')", \ 69 | "INSERT INTO items (def) VALUES ('--- hello\n')", \ 70 | ] 71 | end 72 | 73 | it "should support using the Marshal format" do 74 | @c = Class.new(Sequel::Model(:items)) do 75 | no_primary_key 76 | serialize :abc, :format => :marshal 77 | end 78 | 79 | @c.create(:abc => 1) 80 | @c.create(:abc => "hello") 81 | 82 | MODEL_DB.sqls.should == [ \ 83 | "INSERT INTO items (abc) VALUES ('\004\bi\006')", \ 84 | "INSERT INTO items (abc) VALUES ('\004\b\"\nhello')", \ 85 | ] 86 | end 87 | 88 | it "should translate values to and from YAML using accessor methods" do 89 | @c = Class.new(Sequel::Model(:items)) do 90 | serialize :abc, :def 91 | end 92 | 93 | ds = @c.dataset 94 | ds.extend(Module.new { 95 | attr_accessor :raw 96 | 97 | def fetch_rows(sql, &block) 98 | block.call(@raw) 99 | end 100 | 101 | @@sqls = nil 102 | 103 | def insert(*args) 104 | @@sqls = insert_sql(*args) 105 | end 106 | 107 | def update(*args) 108 | @@sqls = update_sql(*args) 109 | end 110 | 111 | def sqls 112 | @@sqls 113 | end 114 | 115 | def columns 116 | [:id, :abc, :def] 117 | end 118 | } 119 | ) 120 | 121 | ds.raw = {:id => 1, :abc => "--- 1\n", :def => "--- hello\n"} 122 | o = @c.first 123 | o.id.should == 1 124 | o.abc.should == 1 125 | o.def.should == "hello" 126 | 127 | o.set(:abc => 23) 128 | ds.sqls.should == "UPDATE items SET abc = '#{23.to_yaml}' WHERE (id = 1)" 129 | 130 | ds.raw = {:id => 1, :abc => "--- 1\n", :def => "--- hello\n"} 131 | o = @c.create(:abc => [1, 2, 3]) 132 | ds.sqls.should == "INSERT INTO items (abc) VALUES ('#{[1, 2, 3].to_yaml}')" 133 | end 134 | 135 | end 136 | 137 | describe Sequel::Model, "super_dataset" do 138 | setup do 139 | MODEL_DB.reset 140 | class SubClass < Sequel::Model(:items) ; end 141 | end 142 | 143 | it "should call the superclass's dataset" do 144 | SubClass.should_receive(:superclass).exactly(3).times.and_return(Sequel::Model(:items)) 145 | Sequel::Model(:items).should_receive(:dataset) 146 | SubClass.super_dataset 147 | end 148 | end 149 | 150 | describe Sequel::Model, "dataset" do 151 | setup do 152 | @a = Class.new(Sequel::Model(:items)) 153 | @b = Class.new(Sequel::Model) 154 | 155 | class Elephant < Sequel::Model(:ele1) 156 | end 157 | 158 | class Maggot < Sequel::Model 159 | end 160 | 161 | class ShoeSize < Sequel::Model 162 | end 163 | 164 | class BootSize < ShoeSize 165 | end 166 | end 167 | 168 | specify "should default to the plural of the class name" do 169 | Maggot.dataset.sql.should == 'SELECT * FROM maggots' 170 | ShoeSize.dataset.sql.should == 'SELECT * FROM shoe_sizes' 171 | end 172 | 173 | specify "should return the dataset for the superclass if available" do 174 | BootSize.dataset.sql.should == 'SELECT * FROM shoe_sizes' 175 | end 176 | 177 | specify "should return the correct dataset if set explicitly" do 178 | Elephant.dataset.sql.should == 'SELECT * FROM ele1' 179 | @a.dataset.sql.should == 'SELECT * FROM items' 180 | end 181 | 182 | specify "should raise if no dataset is explicitly set and the class is anonymous" do 183 | proc {@b.dataset}.should raise_error(Sequel::Error) 184 | end 185 | 186 | specify "should disregard namespaces for the table name" do 187 | module BlahBlah 188 | class MwaHaHa < Sequel::Model 189 | end 190 | end 191 | 192 | BlahBlah::MwaHaHa.dataset.sql.should == 'SELECT * FROM mwa_ha_has' 193 | end 194 | end 195 | 196 | describe "A model class with implicit table name" do 197 | setup do 198 | class Donkey < Sequel::Model 199 | end 200 | end 201 | 202 | specify "should have a dataset associated with the model class" do 203 | Donkey.dataset.model_classes.should == {nil => Donkey} 204 | end 205 | end 206 | 207 | describe "A model inheriting from a model" do 208 | setup do 209 | class Feline < Sequel::Model 210 | end 211 | 212 | class Leopard < Feline 213 | end 214 | end 215 | 216 | specify "should have a dataset associated with itself" do 217 | Feline.dataset.model_classes.should == {nil => Feline} 218 | Leopard.dataset.model_classes.should == {nil => Leopard} 219 | end 220 | end -------------------------------------------------------------------------------- /spec/hooks_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | describe "Model hooks" do 4 | before do 5 | MODEL_DB.reset 6 | 7 | @hooks = [ 8 | :after_initialize, 9 | :before_create, 10 | :after_create, 11 | :before_update, 12 | :after_update, 13 | :before_save, 14 | :after_save, 15 | :before_destroy, 16 | :after_destroy 17 | ] 18 | 19 | # @hooks.each {|h| Sequel::Model.class_def(h) {}} 20 | end 21 | 22 | specify "should be definable using def " do 23 | c = Class.new(Sequel::Model) do 24 | def before_save 25 | "hi there" 26 | end 27 | end 28 | 29 | c.new.before_save.should == 'hi there' 30 | end 31 | 32 | specify "should be definable using a block" do 33 | $adds = [] 34 | c = Class.new(Sequel::Model) do 35 | before_save {$adds << 'hi'} 36 | end 37 | 38 | c.new.before_save 39 | $adds.should == ['hi'] 40 | end 41 | 42 | specify "should be definable using a method name" do 43 | $adds = [] 44 | c = Class.new(Sequel::Model) do 45 | def bye; $adds << 'bye'; end 46 | before_save :bye 47 | end 48 | 49 | c.new.before_save 50 | $adds.should == ['bye'] 51 | end 52 | 53 | specify "should be additive" do 54 | $adds = [] 55 | c = Class.new(Sequel::Model) do 56 | before_save {$adds << 'hyiyie'} 57 | before_save {$adds << 'byiyie'} 58 | end 59 | 60 | c.new.before_save 61 | $adds.should == ['hyiyie', 'byiyie'] 62 | end 63 | 64 | specify "should be inheritable" do 65 | # pending 66 | 67 | $adds = [] 68 | a = Class.new(Sequel::Model) do 69 | before_save {$adds << '123'} 70 | end 71 | 72 | b = Class.new(a) do 73 | before_save {$adds << '456'} 74 | before_save {$adds << '789'} 75 | end 76 | 77 | b.new.before_save 78 | $adds.should == ['123', '456', '789'] 79 | end 80 | 81 | specify "should be overridable in descendant classes" do 82 | $adds = [] 83 | a = Class.new(Sequel::Model) do 84 | before_save {$adds << '123'} 85 | end 86 | 87 | b = Class.new(a) do 88 | def before_save; $adds << '456'; end 89 | end 90 | 91 | a.new.before_save 92 | $adds.should == ['123'] 93 | $adds = [] 94 | b.new.before_save 95 | $adds.should == ['456'] 96 | end 97 | 98 | specify "should stop processing if a hook returns false" do 99 | $flag = true 100 | $adds = [] 101 | 102 | a = Class.new(Sequel::Model) do 103 | before_save {$adds << 'blah'; $flag} 104 | before_save {$adds << 'cruel'} 105 | end 106 | 107 | a.new.before_save 108 | $adds.should == ['blah', 'cruel'] 109 | 110 | # chain should not break on nil 111 | $adds = [] 112 | $flag = nil 113 | a.new.before_save 114 | $adds.should == ['blah', 'cruel'] 115 | 116 | $adds = [] 117 | $flag = false 118 | a.new.before_save 119 | $adds.should == ['blah'] 120 | 121 | b = Class.new(a) do 122 | before_save {$adds << 'mau'} 123 | end 124 | 125 | $adds = [] 126 | b.new.before_save 127 | $adds.should == ['blah'] 128 | end 129 | end 130 | 131 | describe "Model#after_initialize" do 132 | specify "should be called after initialization" do 133 | $values1 = nil 134 | 135 | a = Class.new(Sequel::Model) do 136 | after_initialize do 137 | $values1 = @values.clone 138 | raise Sequel::Error if @values[:blow] 139 | end 140 | end 141 | 142 | a.new(:x => 1, :y => 2) 143 | $values1.should == {:x => 1, :y => 2} 144 | 145 | proc {a.new(:blow => true)}.should raise_error(Sequel::Error) 146 | end 147 | end 148 | 149 | describe "Model#before_create && Model#after_create" do 150 | setup do 151 | MODEL_DB.reset 152 | 153 | @c = Class.new(Sequel::Model(:items)) do 154 | no_primary_key 155 | 156 | before_create {MODEL_DB << "BLAH before"} 157 | after_create {MODEL_DB << "BLAH after"} 158 | end 159 | end 160 | 161 | specify "should be called around new record creation" do 162 | @c.create(:x => 2) 163 | MODEL_DB.sqls.should == [ 164 | 'BLAH before', 165 | 'INSERT INTO items (x) VALUES (2)', 166 | 'BLAH after' 167 | ] 168 | end 169 | end 170 | 171 | describe "Model#before_update && Model#after_update" do 172 | setup do 173 | MODEL_DB.reset 174 | 175 | @c = Class.new(Sequel::Model(:items)) do 176 | before_update {MODEL_DB << "BLAH before"} 177 | after_update {MODEL_DB << "BLAH after"} 178 | end 179 | end 180 | 181 | specify "should be called around record update" do 182 | m = @c.load(:id => 2233) 183 | m.save 184 | MODEL_DB.sqls.should == [ 185 | 'BLAH before', 186 | 'UPDATE items SET id = 2233 WHERE (id = 2233)', 187 | 'BLAH after' 188 | ] 189 | end 190 | end 191 | 192 | describe "Model#before_save && Model#after_save" do 193 | setup do 194 | MODEL_DB.reset 195 | 196 | @c = Class.new(Sequel::Model(:items)) do 197 | before_save {MODEL_DB << "BLAH before"} 198 | after_save {MODEL_DB << "BLAH after"} 199 | end 200 | end 201 | 202 | specify "should be called around record update" do 203 | m = @c.load(:id => 2233) 204 | m.save 205 | MODEL_DB.sqls.should == [ 206 | 'BLAH before', 207 | 'UPDATE items SET id = 2233 WHERE (id = 2233)', 208 | 'BLAH after' 209 | ] 210 | end 211 | 212 | specify "should be called around record creation" do 213 | @c.no_primary_key 214 | @c.create(:x => 2) 215 | MODEL_DB.sqls.should == [ 216 | 'BLAH before', 217 | 'INSERT INTO items (x) VALUES (2)', 218 | 'BLAH after' 219 | ] 220 | end 221 | end 222 | 223 | describe "Model#before_destroy && Model#after_destroy" do 224 | setup do 225 | MODEL_DB.reset 226 | 227 | @c = Class.new(Sequel::Model(:items)) do 228 | before_destroy {MODEL_DB << "BLAH before"} 229 | after_destroy {MODEL_DB << "BLAH after"} 230 | 231 | def delete 232 | MODEL_DB << "DELETE BLAH" 233 | end 234 | end 235 | end 236 | 237 | specify "should be called around record update" do 238 | m = @c.new(:id => 2233) 239 | m.destroy 240 | MODEL_DB.sqls.should == [ 241 | 'BLAH before', 242 | 'DELETE BLAH', 243 | 'BLAH after' 244 | ] 245 | end 246 | end 247 | 248 | describe "Model#has_hooks?" do 249 | setup do 250 | @c = Class.new(Sequel::Model) 251 | end 252 | 253 | specify "should return false if no hooks are defined" do 254 | @c.has_hooks?(:before_save).should be_false 255 | end 256 | 257 | specify "should return true if hooks are defined" do 258 | @c.before_save {'blah'} 259 | @c.has_hooks?(:before_save).should be_true 260 | end 261 | 262 | specify "should return true if hooks are inherited" do 263 | @d = Class.new(@c) 264 | @d.has_hooks?(:before_save).should be_false 265 | 266 | @c.before_save :blah 267 | @d.has_hooks?(:before_save).should be_true 268 | end 269 | end -------------------------------------------------------------------------------- /spec/validations_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | describe Sequel::Model, "Validations" do 4 | 5 | before(:all) do 6 | class Person < Sequel::Model 7 | def columns 8 | [:id,:name,:first_name,:last_name,:middle_name,:initials,:age, :terms] 9 | end 10 | end 11 | 12 | class Smurf < Person 13 | end 14 | 15 | class Cow < Sequel::Model 16 | def columns 17 | [:id, :name, :got_milk] 18 | end 19 | end 20 | 21 | class User < Sequel::Model 22 | def columns 23 | [:id, :username, :password] 24 | end 25 | end 26 | 27 | class Address < Sequel::Model 28 | def columns 29 | [:id, :zip_code] 30 | end 31 | end 32 | end 33 | 34 | it "should validate the acceptance of a column" do 35 | class Cow < Sequel::Model 36 | validations.clear 37 | validates_acceptance_of :got_milk, :accept => 'blah', :allow_nil => false 38 | end 39 | 40 | @cow = Cow.new 41 | @cow.should_not be_valid 42 | @cow.errors.full_messages.should == ["got_milk is not accepted"] 43 | 44 | @cow.got_milk = "blah" 45 | @cow.should be_valid 46 | end 47 | 48 | it "should validate the confirmation of a column" do 49 | class User < Sequel::Model 50 | def password_confirmation 51 | "test" 52 | end 53 | 54 | validations.clear 55 | validates_confirmation_of :password 56 | end 57 | 58 | @user = User.new 59 | @user.should_not be_valid 60 | @user.errors.full_messages.should == ["password is not confirmed"] 61 | 62 | @user.password = "test" 63 | @user.should be_valid 64 | end 65 | 66 | it "should validate format of column" do 67 | class Person < Sequel::Model 68 | validates_format_of :first_name, :with => /^[a-zA-Z]+$/ 69 | end 70 | 71 | @person = Person.new :first_name => "Lancelot99" 72 | @person.valid?.should be_false 73 | @person = Person.new :first_name => "Anita" 74 | @person.valid?.should be_true 75 | end 76 | 77 | # it "should allow for :with_exactly => /[a-zA-Z]/, which wraps the supplied regex with ^$" do 78 | # pending("TODO: Add this option to Validatable#validates_format_of") 79 | # end 80 | 81 | it "should validate length of column" do 82 | class Person < Sequel::Model 83 | validations.clear 84 | validates_length_of :first_name, :maximum => 30 85 | validates_length_of :last_name, :minimum => 30 86 | validates_length_of :middle_name, :within => 1..5 87 | validates_length_of :initials, :is => 2 88 | end 89 | 90 | @person = Person.new( 91 | :first_name => "Anamethatiswaytofreakinglongandwayoverthirtycharacters", 92 | :last_name => "Alastnameunderthirtychars", 93 | :initials => "LGC", 94 | :middle_name => "danger" 95 | ) 96 | 97 | @person.should_not be_valid 98 | @person.errors.full_messages.size.should == 4 99 | @person.errors.full_messages.should include( 100 | 'first_name is too long', 101 | 'last_name is too short', 102 | 'middle_name is the wrong length', 103 | 'initials is the wrong length' 104 | ) 105 | 106 | @person.first_name = "Lancelot" 107 | @person.last_name = "1234567890123456789012345678901" 108 | @person.initials = "LC" 109 | @person.middle_name = "Will" 110 | @person.should be_valid 111 | end 112 | 113 | it "should validate numericality of column" do 114 | class Person < Sequel::Model 115 | validations.clear 116 | validates_numericality_of :age 117 | end 118 | 119 | @person = Person.new :age => "Twenty" 120 | @person.should_not be_valid 121 | @person.errors.full_messages.should == ['age is not a number'] 122 | 123 | @person.age = 20 124 | @person.should be_valid 125 | end 126 | 127 | it "should validate the presence of a column" do 128 | class Cow < Sequel::Model 129 | validations.clear 130 | validates_presence_of :name 131 | end 132 | 133 | @cow = Cow.new 134 | @cow.should_not be_valid 135 | @cow.errors.full_messages.should == ['name is not present'] 136 | 137 | @cow.name = "Betsy" 138 | @cow.should be_valid 139 | end 140 | 141 | it "should have a validates block that contains multiple validations" do 142 | class Person < Sequel::Model 143 | validations.clear 144 | validates do 145 | format_of :first_name, :with => /^[a-zA-Z]+$/ 146 | length_of :first_name, :maximum => 30 147 | end 148 | end 149 | 150 | Person.validations[:first_name].size.should == 2 151 | 152 | @person = Person.new :first_name => "Lancelot99" 153 | @person.valid?.should be_false 154 | 155 | @person2 = Person.new :first_name => "Wayne" 156 | @person2.valid?.should be_true 157 | end 158 | 159 | it "should allow 'longhand' validations direcly within the model." do 160 | lambda { 161 | class Person < Sequel::Model 162 | validations.clear 163 | validates_length_of :first_name, :maximum => 30 164 | end 165 | }.should_not raise_error 166 | Person.validations.length.should eql(1) 167 | end 168 | 169 | it "should define a has_validations? method which returns true if the model has validations, false otherwise" do 170 | class Person < Sequel::Model 171 | validations.clear 172 | validates do 173 | format_of :first_name, :with => /\w+/ 174 | length_of :first_name, :maximum => 30 175 | end 176 | end 177 | 178 | class Smurf < Person 179 | validations.clear 180 | end 181 | 182 | Person.should have_validations 183 | Smurf.should_not have_validations 184 | end 185 | 186 | it "should validate correctly instances initialized with string keys" do 187 | class Can < Sequel::Model 188 | def columns; [:id, :name]; end 189 | 190 | validates_length_of :name, :minimum => 4 191 | end 192 | 193 | Can.new('name' => 'ab').should_not be_valid 194 | Can.new('name' => 'abcd').should be_valid 195 | end 196 | 197 | end 198 | 199 | describe "Model#save!" do 200 | setup do 201 | @c = Class.new(Sequel::Model(:people)) do 202 | def columns; [:id]; end 203 | 204 | validates_each :id do |o, a, v| 205 | o.errors[a] << 'blah' unless v == 5 206 | end 207 | end 208 | @m = @c.load(:id => 4) 209 | MODEL_DB.reset 210 | end 211 | 212 | specify "should save regardless of validations" do 213 | @m.should_not be_valid 214 | @m.save! 215 | MODEL_DB.sqls.should == ['UPDATE people SET id = 4 WHERE (id = 4)'] 216 | end 217 | end 218 | 219 | describe "Model#save" do 220 | setup do 221 | @c = Class.new(Sequel::Model(:people)) do 222 | def columns; [:id]; end 223 | 224 | validates_each :id do |o, a, v| 225 | o.errors[a] << 'blah' unless v == 5 226 | end 227 | end 228 | @m = @c.load(:id => 4) 229 | MODEL_DB.reset 230 | end 231 | 232 | specify "should save only if validations pass" do 233 | @m.should_not be_valid 234 | @m.save 235 | MODEL_DB.sqls.should be_empty 236 | 237 | @m.id = 5 238 | @m.should be_valid 239 | @m.save.should_not be_false 240 | MODEL_DB.sqls.should == ['UPDATE people SET id = 5 WHERE (id = 5)'] 241 | end 242 | 243 | specify "should return false if validations fail" do 244 | @m.save.should == false 245 | end 246 | end -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | == Sequel: Concise ORM for Ruby 2 | 3 | Sequel is an ORM framework for Ruby. Sequel provides thread safety, connection pooling, and a concise DSL for constructing queries and table schemas. 4 | 5 | Sequel makes it easy to deal with multiple records without having to break your teeth on SQL. 6 | 7 | == Resources 8 | 9 | * {Project page}[http://code.google.com/p/ruby-sequel/] 10 | * {Source code}[http://ruby-sequel.googlecode.com/svn/] 11 | * {Bug tracking}[http://code.google.com/p/ruby-sequel/issues/list] 12 | * {Google group}[http://groups.google.com/group/sequel-talk] 13 | * {RubyForge page}[http://rubyforge.org/projects/sequel/] 14 | 15 | To check out the source code: 16 | 17 | svn co http://ruby-sequel.googlecode.com/svn/trunk 18 | 19 | === Contact 20 | 21 | If you have any comments or suggestions please send an email to ciconia at gmail.com and I'll get back to you. 22 | 23 | == Installation 24 | 25 | sudo gem install sequel 26 | 27 | == Supported Databases 28 | 29 | Sequel currently supports: 30 | 31 | * ADO (on Windows) 32 | * DBI 33 | * Informix 34 | * MySQL 35 | * ODBC 36 | * Oracle 37 | * PostgreSQL 38 | * SQLite 3 39 | 40 | There are also experimental adapters for DB2, OpenBase and JDBC (on JRuby). 41 | 42 | == The Sequel Console 43 | 44 | Sequel includes an IRB console for quick'n'dirty access to databases. You can use it like this: 45 | 46 | sequel sqlite:///test.db 47 | 48 | You get an IRB session with the database object stored in DB. 49 | 50 | == An Introduction 51 | 52 | Sequel was designed to take the hassle away from connecting to databases and manipulating them. Sequel deals with all the boring stuff like maintaining connections, formatting SQL correctly and fetching records so you can concentrate on your application. 53 | 54 | Sequel uses the concept of datasets to retrieve data. A Dataset object encapsulates an SQL query and supports chainability, letting you fetch data using a convenient Ruby DSL that is both concise and infinitely flexible. 55 | 56 | For example, the following one-liner returns the average GDP for the five biggest countries in the middle east region: 57 | 58 | DB[:countries].filter(:region => 'Middle East').reverse_order(:area).limit(5).avg(:GDP) 59 | 60 | Which is equivalent to: 61 | 62 | SELECT avg(GDP) FROM countries WHERE region = 'Middle East' ORDER BY area DESC LIMIT 5 63 | 64 | Since datasets retrieve records only when needed, they can be stored and later reused. Records are fetched as hashes (they can also be fetched as custom model objects), and are accessed using an Enumerable interface: 65 | 66 | middle_east = DB[:countries].filter(:region => 'Middle East') 67 | middle_east.order(:name).each {|r| puts r[:name]} 68 | 69 | Sequel also offers convenience methods for extracting data from Datasets, such as an extended map method: 70 | 71 | middle_east.map(:name) #=> ['Egypt', 'Greece', 'Israel', ...] 72 | 73 | Or getting results as a transposed hash, with one column as key and another as value: 74 | 75 | middle_east.to_hash(:name, :area) #=> {'Israel' => 20000, 'Greece' => 120000, ...} 76 | 77 | Much of Sequel is still undocumented (especially the part relating to model classes). The following section provides examples of common usage. Feel free to explore... 78 | 79 | == Getting Started 80 | 81 | === Connecting to a database 82 | 83 | To connect to a database you simply provide Sequel with a URL: 84 | 85 | require 'sequel' 86 | DB = Sequel.open 'sqlite:///blog.db' 87 | 88 | The connection URL can also include such stuff as the user name and password: 89 | 90 | DB = Sequel.open 'postgres://cico:12345@localhost:5432/mydb' 91 | 92 | You can also specify optional parameters, such as the connection pool size, or a logger for logging SQL queries: 93 | 94 | DB = Sequel.open("postgres://postgres:postgres@localhost/my_db", 95 | :max_connections => 10, :logger => Logger.new('log/db.log')) 96 | 97 | === Arbitrary SQL queries 98 | 99 | DB.execute("create table t (a text, b text)") 100 | DB.execute("insert into t values ('a', 'b')") 101 | 102 | Or more succinctly: 103 | 104 | DB << "create table t (a text, b text)" 105 | DB << "insert into t values ('a', 'b')" 106 | 107 | === Getting Dataset Instances 108 | 109 | Dataset is the primary means through which records are retrieved and manipulated. You can create an blank dataset by using the dataset method: 110 | 111 | dataset = DB.dataset 112 | 113 | Or by using the from methods: 114 | 115 | posts = DB.from(:posts) 116 | 117 | You can also use the equivalent shorthand: 118 | 119 | posts = DB[:posts] 120 | 121 | Note: the dataset will only fetch records when you explicitly ask for them, as will be shown below. Datasets can be manipulated to filter through records, change record order and even join tables, as will also be shown below. 122 | 123 | === Retrieving Records 124 | 125 | You can retrieve records by using the all method: 126 | 127 | posts.all 128 | 129 | The all method returns an array of hashes, where each hash corresponds to a record. 130 | 131 | You can also iterate through records one at a time: 132 | 133 | posts.each {|row| p row} 134 | 135 | Or perform more advanced stuff: 136 | 137 | posts.map(:id) 138 | posts.inject({}) {|h, r| h[r[:id]] = r[:name]} 139 | 140 | You can also retrieve the first record in a dataset: 141 | 142 | posts.first 143 | 144 | Or retrieve a single record with a specific value: 145 | 146 | posts[:id => 1] 147 | 148 | If the dataset is ordered, you can also ask for the last record: 149 | 150 | posts.order(:stamp).last 151 | 152 | === Filtering Records 153 | 154 | The simplest way to filter records is to provide a hash of values to match: 155 | 156 | my_posts = posts.filter(:category => 'ruby', :author => 'david') 157 | 158 | You can also specify ranges: 159 | 160 | my_posts = posts.filter(:stamp => (2.weeks.ago)..(1.week.ago)) 161 | 162 | Or lists of values: 163 | 164 | my_posts = posts.filter(:category => ['ruby', 'postgres', 'linux']) 165 | 166 | Sequel now also accepts expressions as closures, AKA block filters: 167 | 168 | my_posts = posts.filter {:category == ['ruby', 'postgres', 'linux']} 169 | 170 | Which also lets you do stuff like: 171 | 172 | my_posts = posts.filter {:stamp > 1.month.ago} 173 | 174 | Some adapters (like postgresql) will also let you specify Regexps: 175 | 176 | my_posts = posts.filter(:category => /ruby/i) 177 | 178 | You can also use an inverse filter: 179 | 180 | my_posts = posts.exclude(:category => /ruby/i) 181 | 182 | You can then retrieve the records by using any of the retrieval methods: 183 | 184 | my_posts.each {|row| p row} 185 | 186 | You can also specify a custom WHERE clause: 187 | 188 | posts.filter('(stamp < ?) AND (author <> ?)', 3.days.ago, author_name) 189 | 190 | Datasets can also be used as subqueries: 191 | 192 | DB[:items].filter('price > ?', DB[:items].select('AVG(price) + 100')) 193 | 194 | === Summarizing Records 195 | 196 | Counting records is easy: 197 | posts.filter(:category => /ruby/i).count 198 | 199 | And you can also query maximum/minimum values: 200 | max_value = DB[:history].max(:value) 201 | 202 | Or calculate a sum: 203 | total = DB[:items].sum(:price) 204 | 205 | === Ordering Records 206 | 207 | posts.order(:stamp) 208 | 209 | You can also specify descending order 210 | 211 | posts.order(:stamp.DESC) 212 | 213 | === Deleting Records 214 | 215 | posts.filter('stamp < ?', 3.days.ago).delete 216 | 217 | === Inserting Records 218 | 219 | posts.insert(:category => 'ruby', :author => 'david') 220 | 221 | Or alternatively: 222 | 223 | posts << {:category => 'ruby', :author => 'david'} 224 | 225 | === Updating Records 226 | 227 | posts.filter('stamp < ?', 3.days.ago).update(:state => 'archived') 228 | 229 | === Joining Tables 230 | 231 | Joining is very useful in a variety of scenarios, for example many-to-many relationships. With Sequel it's really easy: 232 | 233 | order_items = DB[:items].join(:order_items, :item_id => :id). 234 | filter(:order_items__order_id => 1234) 235 | 236 | This is equivalent to the SQL: 237 | 238 | SELECT * FROM items LEFT OUTER JOIN order_items 239 | ON order_items.item_id = items.id 240 | WHERE order_items.order_id = 1234 241 | 242 | You can then do anything you like with the dataset: 243 | 244 | order_total = order_items.sum(:price) 245 | 246 | Which is equivalent to the SQL: 247 | 248 | SELECT sum(price) FROM items LEFT OUTER JOIN order_items 249 | ON order_items.item_id = items.id 250 | WHERE order_items.order_id = 1234 251 | 252 | -------------------------------------------------------------------------------- /lib/sequel_model/record.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | attr_reader :values 4 | attr_reader :changed_columns 5 | 6 | # Returns value of attribute. 7 | def [](column) 8 | @values[column] 9 | end 10 | # Sets value of attribute and marks the column as changed. 11 | def []=(column, value) 12 | @values[column] = value 13 | @changed_columns << column unless @changed_columns.include?(column) 14 | end 15 | 16 | # Enumerates through all attributes. 17 | # 18 | # === Example: 19 | # Ticket.find(7).each { |k, v| puts "#{k} => #{v}" } 20 | def each(&block) 21 | @values.each(&block) 22 | end 23 | # Returns attribute names. 24 | def keys 25 | @values.keys 26 | end 27 | 28 | # Returns value for :id attribute. 29 | def id 30 | @values[:id] 31 | end 32 | 33 | # Compares model instances by values. 34 | def ==(obj) 35 | (obj.class == model) && (obj.values == @values) 36 | end 37 | 38 | # Compares model instances by pkey. 39 | def ===(obj) 40 | (obj.class == model) && (obj.pk == pk) 41 | end 42 | 43 | # Returns key for primary key. 44 | def self.primary_key 45 | :id 46 | end 47 | 48 | # Returns primary key attribute hash. 49 | def self.primary_key_hash(value) 50 | {:id => value} 51 | end 52 | 53 | # returns the primary keys and their types as a hash 54 | def self.primary_keys_hash 55 | # TODO: make work for compound primary keys 56 | {:id => "integer"} 57 | end 58 | 59 | # Sets primary key, regular and composite are possible. 60 | # 61 | # == Example: 62 | # class Tagging < Sequel::Model 63 | # # composite key 64 | # set_primary_key :taggable_id, :tag_id 65 | # end 66 | # 67 | # class Person < Sequel::Model 68 | # # regular key 69 | # set_primary_key :person_id 70 | # end 71 | # 72 | # You can even set it to nil! 73 | def self.set_primary_key(*key) 74 | # if k is nil, we go to no_primary_key 75 | if key.empty? || (key.size == 1 && key.first == nil) 76 | return no_primary_key 77 | end 78 | 79 | # backwards compat 80 | key = (key.length == 1) ? key[0] : key.flatten 81 | 82 | # redefine primary_key 83 | meta_def(:primary_key) {key} 84 | 85 | unless key.is_a? Array # regular primary key 86 | class_def(:this) do 87 | @this ||= dataset.filter(key => @values[key]).limit(1).naked 88 | end 89 | class_def(:pk) do 90 | @pk ||= @values[key] 91 | end 92 | class_def(:pk_hash) do 93 | @pk ||= {key => @values[key]} 94 | end 95 | class_def(:cache_key) do 96 | pk = @values[key] || (raise Error, 'no primary key for this record') 97 | @cache_key ||= "#{self.class}:#{pk}" 98 | end 99 | meta_def(:primary_key_hash) do |v| 100 | {key => v} 101 | end 102 | else # composite key 103 | exp_list = key.map {|k| "#{k.inspect} => @values[#{k.inspect}]"} 104 | block = eval("proc {@this ||= self.class.dataset.filter(#{exp_list.join(',')}).limit(1).naked}") 105 | class_def(:this, &block) 106 | 107 | exp_list = key.map {|k| "@values[#{k.inspect}]"} 108 | block = eval("proc {@pk ||= [#{exp_list.join(',')}]}") 109 | class_def(:pk, &block) 110 | 111 | exp_list = key.map {|k| "#{k.inspect} => @values[#{k.inspect}]"} 112 | block = eval("proc {@this ||= {#{exp_list.join(',')}}}") 113 | class_def(:pk_hash, &block) 114 | 115 | exp_list = key.map {|k| '#{@values[%s]}' % k.inspect}.join(',') 116 | block = eval('proc {@cache_key ||= "#{self.class}:%s"}' % exp_list) 117 | class_def(:cache_key, &block) 118 | 119 | meta_def(:primary_key_hash) do |v| 120 | key.inject({}) {|m, i| m[i] = v.shift; m} 121 | end 122 | end 123 | end 124 | 125 | def self.no_primary_key #:nodoc: 126 | meta_def(:primary_key) {nil} 127 | meta_def(:primary_key_hash) {|v| raise Error, "#{self} does not have a primary key"} 128 | class_def(:this) {raise Error, "No primary key is associated with this model"} 129 | class_def(:pk) {raise Error, "No primary key is associated with this model"} 130 | class_def(:pk_hash) {raise Error, "No primary key is associated with this model"} 131 | class_def(:cache_key) {raise Error, "No primary key is associated with this model"} 132 | end 133 | 134 | # Creates new instance with values set to passed-in Hash ensuring that 135 | # new? returns true. 136 | def self.create(values = {}, &block) 137 | db.transaction do 138 | obj = new(values, &block) 139 | obj.save 140 | obj 141 | end 142 | end 143 | 144 | # Updates the instance with the supplied values with support for virtual 145 | # attributes, ignoring any values for which no setter method is available. 146 | def update_with_params(values) 147 | c = columns 148 | values.each do |k, v| m = :"#{k}=" 149 | send(m, v) if c.include?(k) || respond_to?(m) 150 | end 151 | save_changes 152 | end 153 | alias_method :update_with, :update_with_params 154 | 155 | class << self 156 | def create_with_params(params) 157 | record = new 158 | record.update_with_params(params) 159 | record 160 | end 161 | alias_method :create_with, :create_with_params 162 | end 163 | 164 | # Returns (naked) dataset bound to current instance. 165 | def this 166 | @this ||= self.class.dataset.filter(:id => @values[:id]).limit(1).naked 167 | end 168 | 169 | # Returns a key unique to the underlying record for caching 170 | def cache_key 171 | pk = @values[:id] || (raise Error, 'no primary key for this record') 172 | @cache_key ||= "#{self.class}:#{pk}" 173 | end 174 | 175 | # Returns primary key column(s) for object's Model class. 176 | def primary_key 177 | @primary_key ||= self.class.primary_key 178 | end 179 | 180 | # Returns the primary key value identifying the model instance. If the 181 | # model's primary key is changed (using #set_primary_key or #no_primary_key) 182 | # this method is redefined accordingly. 183 | def pk 184 | @pk ||= @values[:id] 185 | end 186 | 187 | # Returns a hash identifying the model instance. Stock implementation. 188 | def pk_hash 189 | @pk_hash ||= {:id => @values[:id]} 190 | end 191 | 192 | # Creates new instance with values set to passed-in Hash. 193 | # 194 | # This method guesses whether the record exists when 195 | # new_record is set to false. 196 | def initialize(values = nil, from_db = false, &block) 197 | @changed_columns = [] 198 | unless from_db 199 | @values = {} 200 | if values 201 | values.each do |k, v| m = :"#{k}=" 202 | if respond_to?(m) 203 | send(m, v) 204 | values.delete(k) 205 | end 206 | end 207 | values.inject(@values) {|m, kv| m[kv[0].to_sym] = kv[1]; m} 208 | # @values.merge!(values) 209 | end 210 | else 211 | @values = values || {} 212 | end 213 | 214 | k = primary_key 215 | @new = !from_db 216 | 217 | block[self] if block 218 | after_initialize 219 | end 220 | 221 | # Initializes a model instance as an existing record. This constructor is 222 | # used by Sequel to initialize model instances when fetching records. 223 | def self.load(values) 224 | new(values, true) 225 | end 226 | 227 | # Returns true if the current instance represents a new record. 228 | def new? 229 | @new 230 | end 231 | alias :new_record? :new? 232 | 233 | # Returns true when current instance exists, false otherwise. 234 | def exists? 235 | this.count > 0 236 | end 237 | 238 | # Creates or updates the associated record. This method can also 239 | # accept a list of specific columns to update. 240 | def save(*columns) 241 | before_save 242 | if @new 243 | before_create 244 | iid = model.dataset.insert(@values) 245 | # if we have a regular primary key and it's not set in @values, 246 | # we assume it's the last inserted id 247 | if (pk = primary_key) && !(Array === pk) && !@values[pk] 248 | @values[pk] = iid 249 | end 250 | if pk 251 | @this = nil # remove memoized this dataset 252 | refresh 253 | end 254 | @new = false 255 | after_create 256 | else 257 | before_update 258 | if columns.empty? 259 | this.update(@values) 260 | @changed_columns = [] 261 | else # update only the specified columns 262 | this.update(@values.reject {|k, v| !columns.include?(k)}) 263 | @changed_columns.reject! {|c| columns.include?(c)} 264 | end 265 | after_update 266 | end 267 | after_save 268 | self 269 | end 270 | 271 | # Saves only changed columns or does nothing if no columns are marked as 272 | # chanaged. 273 | def save_changes 274 | save(*@changed_columns) unless @changed_columns.empty? 275 | end 276 | 277 | # Updates and saves values to database from the passed-in Hash. 278 | def set(values) 279 | v = values.inject({}) {|m, kv| m[kv[0].to_sym] = kv[1]; m} 280 | this.update(v) 281 | v.each {|k, v| @values[k] = v} 282 | end 283 | alias_method :update, :set 284 | 285 | # Reloads values from database and returns self. 286 | def refresh 287 | @values = this.first || raise(Error, "Record not found") 288 | self 289 | end 290 | alias_method :reload, :refresh 291 | 292 | # Like delete but runs hooks before and after delete. 293 | def destroy 294 | db.transaction do 295 | before_destroy 296 | delete 297 | after_destroy 298 | end 299 | end 300 | 301 | # Deletes and returns self. 302 | def delete 303 | this.delete 304 | self 305 | end 306 | 307 | ATTR_RE = /^([a-zA-Z_]\w*)(=)?$/.freeze 308 | EQUAL_SIGN = '='.freeze 309 | 310 | def method_missing(m, *args) #:nodoc: 311 | if m.to_s =~ ATTR_RE 312 | att = $1.to_sym 313 | write = $2 == EQUAL_SIGN 314 | 315 | # check whether the column is legal 316 | unless @values.has_key?(att) || columns.include?(att) 317 | raise Error, "Invalid column (#{att.inspect}) for #{self}" 318 | end 319 | 320 | # define the column accessor 321 | Thread.exclusive do 322 | if write 323 | model.class_def(m) {|v| self[att] = v} 324 | else 325 | model.class_def(m) {self[att]} 326 | end 327 | end 328 | 329 | # call the accessor 330 | respond_to?(m) ? send(m, *args) : super(m, *args) 331 | else 332 | super(m, *args) 333 | end 334 | end 335 | end 336 | end -------------------------------------------------------------------------------- /lib/sequel_model.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | class Model 3 | alias_method :model, :class 4 | end 5 | end 6 | 7 | files = %w[ 8 | base hooks record schema relations 9 | caching plugins validations relationships 10 | ] 11 | dir = File.join(File.dirname(__FILE__), "sequel_model") 12 | files.each {|f| require(File.join(dir, f))} 13 | 14 | module Sequel 15 | # == Sequel Models 16 | # 17 | # Models in Sequel are based on the Active Record pattern described by Martin Fowler (http://www.martinfowler.com/eaaCatalog/activeRecord.html). A model class corresponds to a table or a dataset, and an instance of that class wraps a single record in the model's underlying dataset. 18 | # 19 | # Model classes are defined as regular Ruby classes: 20 | # 21 | # DB = Sequel('sqlite:/blog.db') 22 | # class Post < Sequel::Model 23 | # set_dataset DB[:posts] 24 | # end 25 | # 26 | # You can also use the shorthand form: 27 | # 28 | # DB = Sequel('sqlite:/blog.db') 29 | # class Post < Sequel::Model 30 | # end 31 | # 32 | # === Model instances 33 | # 34 | # Model instance are identified by a primary key. By default, Sequel assumes the primary key column to be :id. The Model#[] method can be used to fetch records by their primary key: 35 | # 36 | # post = Post[123] 37 | # 38 | # The Model#pk method is used to retrieve the record's primary key value: 39 | # 40 | # post.pk #=> 123 41 | # 42 | # Sequel models allow you to use any column as a primary key, and even composite keys made from multiple columns: 43 | # 44 | # class Post < Sequel::Model 45 | # set_primary_key [:category, :title] 46 | # end 47 | # 48 | # post = Post['ruby', 'hello world'] 49 | # post.pk #=> ['ruby', 'hello world'] 50 | # 51 | # You can also define a model class that does not have a primary key, but then you lose the ability to update records. 52 | # 53 | # A model instance can also be fetched by specifying a condition: 54 | # 55 | # post = Post[:title => 'hello world'] 56 | # post = Post.find {:stamp < 10.days.ago} 57 | # 58 | # === Iterating over records 59 | # 60 | # A model class lets you iterate over specific records by acting as a proxy to the underlying dataset. This means that you can use the entire Dataset API to create customized queries that return model instances, e.g.: 61 | # 62 | # Post.filter(:category => 'ruby').each {|post| p post} 63 | # 64 | # You can also manipulate the records in the dataset: 65 | # 66 | # Post.filter {:stamp < 7.days.ago}.delete 67 | # Post.filter {:title =~ /ruby/}.update(:category => 'ruby') 68 | # 69 | # === Accessing record values 70 | # 71 | # A model instances stores its values as a hash: 72 | # 73 | # post.values #=> {:id => 123, :category => 'ruby', :title => 'hello world'} 74 | # 75 | # You can read the record values as object attributes: 76 | # 77 | # post.id #=> 123 78 | # post.title #=> 'hello world' 79 | # 80 | # You can also change record values: 81 | # 82 | # post.title = 'hey there' 83 | # post.save 84 | # 85 | # Another way to change values by using the #set method: 86 | # 87 | # post.set(:title => 'hey there') 88 | # 89 | # === Creating new records 90 | # 91 | # New records can be created by calling Model.create: 92 | # 93 | # post = Post.create(:title => 'hello world') 94 | # 95 | # Another way is to construct a new instance and save it: 96 | # 97 | # post = Post.new 98 | # post.title = 'hello world' 99 | # post.save 100 | # 101 | # You can also supply a block to Model.new and Model.create: 102 | # 103 | # post = Post.create {|p| p.title = 'hello world'} 104 | # 105 | # post = Post.new do |p| 106 | # p.title = 'hello world' 107 | # p.save 108 | # end 109 | # 110 | # === Hooks 111 | # 112 | # You can execute custom code when creating, updating, or deleting records by using hooks. The before_create and after_create hooks wrap record creation. The before_update and after_update wrap record updating. The before_save and after_save wrap record creation and updating. The before_destroy and after_destroy wrap destruction. 113 | # 114 | # Hooks are defined by supplying a block: 115 | # 116 | # class Post < Sequel::Model 117 | # after_create do 118 | # set(:created_at => Time.now) 119 | # end 120 | # 121 | # after_destroy do 122 | # author.update_post_count 123 | # end 124 | # end 125 | # 126 | # === Deleting records 127 | # 128 | # You can delete individual records by calling #delete or #destroy. The only difference between the two methods is that #destroy invokes before_destroy and after_destroy hooks, while #delete does not: 129 | # 130 | # post.delete #=> bypasses hooks 131 | # post.destroy #=> runs hooks 132 | # 133 | # Records can also be deleted en-masse by invoking Model.delete and Model.destroy. As stated above, you can specify filters for the deleted records: 134 | # 135 | # Post.filter(:category => 32).delete #=> bypasses hooks 136 | # Post.filter(:category => 32).destroy #=> runs hooks 137 | # 138 | # Please note that if Model.destroy is called, each record is deleted separately, but Model.delete deletes all relevant records with a single SQL statement. 139 | # 140 | # === Associations 141 | # 142 | # The most straightforward way to define an association in a Sequel model is as a regular instance method: 143 | # 144 | # class Post < Sequel::Model 145 | # def author; Author[author_id]; end 146 | # end 147 | # 148 | # class Author < Sequel::Model 149 | # def posts; Post.filter(:author_id => pk); end 150 | # end 151 | # 152 | # Sequel also provides two macros to assist with common types of associations. The one_to_one macro is roughly equivalent to ActiveRecord?'s belongs_to macro. It defines both getter and setter methods for the association: 153 | # 154 | # class Post < Sequel::Model 155 | # one_to_one :author, :from => Author 156 | # end 157 | # 158 | # post = Post.create(:name => 'hi!') 159 | # post.author = Author[:name => 'Sharon'] 160 | # 161 | # The one_to_many macro is roughly equivalent to ActiveRecord's has_many macro: 162 | # 163 | # class Author < Sequel::Model 164 | # one_to_many :posts, :from => Post, :key => :author_id 165 | # end 166 | # 167 | # You will have noticed that in some cases the association macros are actually more verbose than hand-coding instance methods. The one_to_one and one_to_many macros also make assumptions (just like ActiveRecord macros) about the database schema which may not be relevant in many cases. 168 | # 169 | # === Caching model instances with memcached 170 | # 171 | # Sequel models can be cached using memcached based on their primary keys. The use of memcached can significantly reduce database load by keeping model instances in memory. The set_cache method is used to specify caching: 172 | # 173 | # require 'memcache' 174 | # CACHE = MemCache.new 'localhost:11211', :namespace => 'blog' 175 | # 176 | # class Author < Sequel::Model 177 | # set_cache CACHE, :ttl => 3600 178 | # end 179 | # 180 | # Author[333] # database hit 181 | # Author[333] # cache hit 182 | # 183 | # === Extending the underlying dataset 184 | # 185 | # The obvious way to add table-wide logic is to define class methods to the model class definition. That way you can define subsets of the underlying dataset, change the ordering, or perform actions on multiple records: 186 | # 187 | # class Post < Sequel::Model 188 | # def self.old_posts 189 | # filter {:stamp < 30.days.ago} 190 | # end 191 | # 192 | # def self.clean_old_posts 193 | # old_posts.delete 194 | # end 195 | # end 196 | # 197 | # You can also implement table-wide logic by defining methods on the dataset: 198 | # 199 | # class Post < Sequel::Model 200 | # def dataset.old_posts 201 | # filter {:stamp < 30.days.ago} 202 | # end 203 | # 204 | # def dataset.clean_old_posts 205 | # old_posts.delete 206 | # end 207 | # end 208 | # 209 | # This is the recommended way of implementing table-wide operations, and allows you to have access to your model API from filtered datasets as well: 210 | # 211 | # Post.filter(:category => 'ruby').clean_old_posts 212 | # 213 | # Sequel models also provide a short hand notation for filters: 214 | # 215 | # class Post < Sequel::Model 216 | # subset(:old_posts) {:stamp < 30.days.ago} 217 | # subset :invisible, :visible => false 218 | # end 219 | # 220 | # === Defining the underlying schema 221 | # 222 | # Model classes can also be used as a place to define your table schema and control it. The schema DSL is exactly the same provided by Sequel::Schema::Generator: 223 | # 224 | # class Post < Sequel::Model 225 | # set_schema do 226 | # primary_key :id 227 | # text :title 228 | # text :category 229 | # foreign_key :author_id, :table => :authors 230 | # end 231 | # end 232 | # 233 | # You can then create the underlying table, drop it, or recreate it: 234 | # 235 | # Post.table_exists? 236 | # Post.create_table 237 | # Post.drop_table 238 | # Post.create_table! # drops the table if it exists and then recreates it 239 | # 240 | 241 | 242 | class Model 243 | 244 | # Returns a string representation of the model instance including 245 | # the class name and values. 246 | def inspect 247 | "#<%s @values=%s>" % [self.class.name, @values.inspect] 248 | end 249 | 250 | # Defines a method that returns a filtered dataset. 251 | def self.subset(name, *args, &block) 252 | dataset.meta_def(name) {filter(*args, &block)} 253 | end 254 | 255 | # Finds a single record according to the supplied filter, e.g.: 256 | # 257 | # Ticket.find :author => 'Sharon' # => record 258 | # Ticket.find {:price == 17} # => Dataset 259 | # 260 | def self.find(*args, &block) 261 | dataset.filter(*args, &block).first 262 | end 263 | 264 | # TODO: doc 265 | def self.[](*args) 266 | args = args.first if (args.size == 1) 267 | if args === true || args === false 268 | raise Error::InvalidFilter, "Did you mean to supply a hash?" 269 | end 270 | dataset[(Hash === args) ? args : primary_key_hash(args)] 271 | end 272 | 273 | # TODO: doc 274 | def self.fetch(*args) 275 | db.fetch(*args).set_model(self) 276 | end 277 | 278 | # Like find but invokes create with given conditions when record does not 279 | # exists. 280 | def self.find_or_create(cond) 281 | find(cond) || create(cond) 282 | end 283 | 284 | ############################################################################ 285 | 286 | # Deletes all records in the model's table. 287 | def self.delete_all 288 | dataset.delete 289 | end 290 | 291 | # Like delete_all, but invokes before_destroy and after_destroy hooks if used. 292 | def self.destroy_all 293 | dataset.destroy 294 | end 295 | 296 | def self.is_dataset_magic_method?(m) 297 | method_name = m.to_s 298 | Sequel::Dataset::MAGIC_METHODS.each_key do |r| 299 | return true if method_name =~ r 300 | end 301 | false 302 | end 303 | 304 | def self.method_missing(m, *args, &block) #:nodoc: 305 | Thread.exclusive do 306 | if dataset.respond_to?(m) || is_dataset_magic_method?(m) 307 | instance_eval("def #{m}(*args, &block); dataset.#{m}(*args, &block); end") 308 | end 309 | end 310 | respond_to?(m) ? send(m, *args, &block) : super(m, *args) 311 | end 312 | 313 | # TODO: Comprehensive description goes here! 314 | def self.join(*args) 315 | table_name = dataset.opts[:from].first 316 | dataset.join(*args).select(table_name.to_sym.ALL) 317 | end 318 | 319 | # Returns an array containing all of the models records. 320 | def self.all 321 | dataset.all 322 | end 323 | end 324 | end 325 | -------------------------------------------------------------------------------- /spec/model_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | describe Sequel::Model do 4 | it "should have class method aliased as model" do 5 | Sequel::Model.instance_methods.should include("model") 6 | 7 | model_a = Class.new Sequel::Model 8 | model_a.new.model.should be(model_a) 9 | end 10 | 11 | it "should be associated with a dataset" do 12 | model_a = Class.new(Sequel::Model) { set_dataset MODEL_DB[:as] } 13 | 14 | model_a.dataset.should be_a_kind_of(MockDataset) 15 | model_a.dataset.opts[:from].should == [:as] 16 | 17 | model_b = Class.new(Sequel::Model) { set_dataset MODEL_DB[:bs] } 18 | 19 | model_b.dataset.should be_a_kind_of(MockDataset) 20 | model_b.dataset.opts[:from].should == [:bs] 21 | 22 | model_a.dataset.opts[:from].should == [:as] 23 | end 24 | 25 | end 26 | 27 | describe Sequel::Model, "dataset & schema" do 28 | 29 | before do 30 | @model = Class.new(Sequel::Model(:items)) 31 | end 32 | 33 | it "creates dynamic model subclass with set table name" do 34 | @model.table_name.should == :items 35 | end 36 | 37 | it "defaults to primary key of id" do 38 | @model.primary_key.should == :id 39 | end 40 | 41 | it "allow primary key change" do 42 | @model.set_primary_key :ssn 43 | @model.primary_key.should == :ssn 44 | end 45 | 46 | it "allows dataset change" do 47 | @model.set_dataset(MODEL_DB[:foo]) 48 | @model.table_name.should == :foo 49 | end 50 | 51 | it "sets schema with implicit table name" do 52 | @model.set_schema do 53 | primary_key :ssn, :string 54 | end 55 | @model.primary_key.should == :ssn 56 | @model.table_name.should == :items 57 | end 58 | 59 | it "sets schema with explicit table name" do 60 | @model.set_schema :foo do 61 | primary_key :id 62 | end 63 | @model.primary_key.should == :id 64 | @model.table_name.should == :foo 65 | end 66 | 67 | it "puts the lotion in the basket or it gets the hose again" do 68 | # just kidding! 69 | end 70 | end 71 | 72 | describe Sequel::Model, "constructor" do 73 | 74 | before(:each) do 75 | @m = Class.new(Sequel::Model) 76 | end 77 | 78 | it "should accept a hash" do 79 | m = @m.new(:a => 1, :b => 2) 80 | m.values.should == {:a => 1, :b => 2} 81 | m.should be_new 82 | end 83 | 84 | it "should accept a block and yield itself to the block" do 85 | block_called = false 86 | m = @m.new {|i| block_called = true; i.should be_a_kind_of(@m); i.values[:a] = 1} 87 | 88 | block_called.should be_true 89 | m.values[:a].should == 1 90 | end 91 | 92 | end 93 | 94 | describe Sequel::Model, "new" do 95 | 96 | before(:each) do 97 | @m = Class.new(Sequel::Model) do 98 | set_dataset MODEL_DB[:items] 99 | end 100 | end 101 | 102 | it "should be marked as new?" do 103 | o = @m.new 104 | o.should be_new 105 | o.should be_new_record 106 | end 107 | 108 | it "should not be marked as new? once it is saved" do 109 | o = @m.new(:x => 1) 110 | o.should be_new 111 | o.save 112 | o.should_not be_new 113 | o.should_not be_new_record 114 | end 115 | 116 | it "should use the last inserted id as primary key if not in values" do 117 | d = @m.dataset 118 | def d.insert(*args) 119 | super 120 | 1234 121 | end 122 | 123 | def d.first 124 | {:x => 1, :id => 1234} 125 | end 126 | 127 | o = @m.new(:x => 1) 128 | o.save 129 | o.id.should == 1234 130 | 131 | o = @m.load(:x => 1, :id => 333) 132 | o.save 133 | o.id.should == 333 134 | end 135 | 136 | end 137 | 138 | describe Sequel::Model, ".subset" do 139 | 140 | before(:each) do 141 | MODEL_DB.reset 142 | 143 | @c = Class.new(Sequel::Model(:items)) 144 | end 145 | 146 | it "should create a filter on the underlying dataset" do 147 | proc {@c.new_only}.should raise_error(NoMethodError) 148 | 149 | @c.subset(:new_only) {:age == 'new'} 150 | 151 | @c.new_only.sql.should == "SELECT * FROM items WHERE (age = 'new')" 152 | @c.dataset.new_only.sql.should == "SELECT * FROM items WHERE (age = 'new')" 153 | 154 | @c.subset(:pricey) {:price > 100} 155 | 156 | @c.pricey.sql.should == "SELECT * FROM items WHERE (price > 100)" 157 | @c.dataset.pricey.sql.should == "SELECT * FROM items WHERE (price > 100)" 158 | 159 | # check if subsets are composable 160 | @c.pricey.new_only.sql.should == "SELECT * FROM items WHERE (price > 100) AND (age = 'new')" 161 | @c.new_only.pricey.sql.should == "SELECT * FROM items WHERE (age = 'new') AND (price > 100)" 162 | end 163 | 164 | end 165 | 166 | describe Sequel::Model, ".find" do 167 | 168 | before(:each) do 169 | MODEL_DB.reset 170 | 171 | @c = Class.new(Sequel::Model(:items)) 172 | 173 | $cache_dataset_row = {:name => 'sharon', :id => 1} 174 | @dataset = @c.dataset 175 | $sqls = [] 176 | @dataset.extend(Module.new { 177 | def fetch_rows(sql) 178 | $sqls << sql 179 | yield $cache_dataset_row 180 | end 181 | }) 182 | end 183 | 184 | it "should return the first record matching the given filter" do 185 | @c.find(:name => 'sharon').should be_a_kind_of(@c) 186 | $sqls.last.should == "SELECT * FROM items WHERE (name = 'sharon') LIMIT 1" 187 | 188 | @c.find {"name LIKE 'abc%'".lit}.should be_a_kind_of(@c) 189 | $sqls.last.should == "SELECT * FROM items WHERE name LIKE 'abc%' LIMIT 1" 190 | end 191 | 192 | it "should accept filter blocks" do 193 | @c.find {:id == 1}.should be_a_kind_of(@c) 194 | $sqls.last.should == "SELECT * FROM items WHERE (id = 1) LIMIT 1" 195 | 196 | @c.find {:x > 1 && :y < 2}.should be_a_kind_of(@c) 197 | $sqls.last.should == "SELECT * FROM items WHERE ((x > 1) AND (y < 2)) LIMIT 1" 198 | end 199 | 200 | end 201 | 202 | describe Sequel::Model, ".fetch" do 203 | 204 | before(:each) do 205 | MODEL_DB.reset 206 | @c = Class.new(Sequel::Model(:items)) 207 | end 208 | 209 | it "should return instances of Model" do 210 | @c.fetch("SELECT * FROM items").first.should be_a_kind_of(@c) 211 | end 212 | 213 | it "should return true for .empty? and not raise an error on empty selection" do 214 | rows = @c.fetch("SELECT * FROM items WHERE FALSE") 215 | @c.class_def(:fetch_rows) {|sql| yield({:count => 0})} 216 | proc {rows.empty?}.should_not raise_error 217 | end 218 | 219 | end 220 | 221 | describe Sequel::Model, "magic methods" do 222 | 223 | before(:each) do 224 | @c = Class.new(Sequel::Dataset) do 225 | @@sqls = [] 226 | 227 | def self.sqls; @@sqls; end 228 | 229 | def fetch_rows(sql) 230 | @@sqls << sql 231 | yield({:id => 123, :name => 'hey'}) 232 | end 233 | end 234 | 235 | @m = Class.new(Sequel::Model(@c.new(nil).from(:items))) 236 | end 237 | 238 | it "should support order_by_xxx" do 239 | @m.order_by_name.should be_a_kind_of(@c) 240 | @m.order_by_name.sql.should == "SELECT * FROM items ORDER BY name" 241 | end 242 | 243 | it "should support group_by_xxx" do 244 | @m.group_by_name.should be_a_kind_of(@c) 245 | @m.group_by_name.sql.should == "SELECT * FROM items GROUP BY name" 246 | end 247 | 248 | it "should support count_by_xxx" do 249 | @m.count_by_name.should be_a_kind_of(@c) 250 | @m.count_by_name.sql.should == "SELECT name, count(*) AS count FROM items GROUP BY name ORDER BY count" 251 | end 252 | 253 | it "should support filter_by_xxx" do 254 | @m.filter_by_name('sharon').should be_a_kind_of(@c) 255 | @m.filter_by_name('sharon').sql.should == "SELECT * FROM items WHERE (name = 'sharon')" 256 | end 257 | 258 | it "should support all_by_xxx" do 259 | all = @m.all_by_name('sharon') 260 | all.class.should == Array 261 | all.size.should == 1 262 | all.first.should be_a_kind_of(@m) 263 | all.first.values.should == {:id => 123, :name => 'hey'} 264 | @c.sqls.should == ["SELECT * FROM items WHERE (name = 'sharon')"] 265 | end 266 | 267 | it "should support find_by_xxx" do 268 | @m.find_by_name('sharon').should be_a_kind_of(@m) 269 | @m.find_by_name('sharon').values.should == {:id => 123, :name => 'hey'} 270 | @c.sqls.should == ["SELECT * FROM items WHERE (name = 'sharon') LIMIT 1"] * 2 271 | end 272 | 273 | it "should support first_by_xxx" do 274 | @m.first_by_name('sharon').should be_a_kind_of(@m) 275 | @m.first_by_name('sharon').values.should == {:id => 123, :name => 'hey'} 276 | @c.sqls.should == ["SELECT * FROM items ORDER BY name LIMIT 1"] * 2 277 | end 278 | 279 | it "should support last_by_xxx" do 280 | @m.last_by_name('sharon').should be_a_kind_of(@m) 281 | @m.last_by_name('sharon').values.should == {:id => 123, :name => 'hey'} 282 | @c.sqls.should == ["SELECT * FROM items ORDER BY name DESC LIMIT 1"] * 2 283 | end 284 | 285 | end 286 | 287 | describe Sequel::Model, ".find_or_create" do 288 | 289 | before(:each) do 290 | MODEL_DB.reset 291 | @c = Class.new(Sequel::Model(:items)) do 292 | no_primary_key 293 | end 294 | end 295 | 296 | it "should find the record" do 297 | @c.find_or_create(:x => 1) 298 | MODEL_DB.sqls.should == ["SELECT * FROM items WHERE (x = 1) LIMIT 1"] 299 | 300 | MODEL_DB.reset 301 | end 302 | 303 | it "should create the record if not found" do 304 | @c.meta_def(:find) do |*args| 305 | dataset.filter(*args).first 306 | nil 307 | end 308 | 309 | @c.find_or_create(:x => 1) 310 | MODEL_DB.sqls.should == [ 311 | "SELECT * FROM items WHERE (x = 1) LIMIT 1", 312 | "INSERT INTO items (x) VALUES (1)" 313 | ] 314 | end 315 | end 316 | 317 | describe Sequel::Model, ".delete_all" do 318 | 319 | before(:each) do 320 | MODEL_DB.reset 321 | @c = Class.new(Sequel::Model(:items)) do 322 | no_primary_key 323 | end 324 | 325 | @c.dataset.meta_def(:delete) {MODEL_DB << delete_sql} 326 | end 327 | 328 | it "should delete all records in the dataset" do 329 | @c.delete_all 330 | MODEL_DB.sqls.should == ["DELETE FROM items"] 331 | end 332 | 333 | end 334 | 335 | describe Sequel::Model, ".destroy_all" do 336 | 337 | before(:each) do 338 | MODEL_DB.reset 339 | @c = Class.new(Sequel::Model(:items)) do 340 | no_primary_key 341 | end 342 | 343 | @c.dataset.meta_def(:delete) {MODEL_DB << delete_sql} 344 | end 345 | 346 | it "should delete all records in the dataset" do 347 | @c.dataset.meta_def(:destroy) {MODEL_DB << "DESTROY this stuff"} 348 | @c.destroy_all 349 | MODEL_DB.sqls.should == ["DESTROY this stuff"] 350 | end 351 | 352 | it "should call dataset.destroy" do 353 | @c.dataset.should_receive(:destroy).and_return(true) 354 | @c.destroy_all 355 | end 356 | end 357 | 358 | describe Sequel::Model, ".join" do 359 | 360 | before(:each) do 361 | MODEL_DB.reset 362 | @c = Class.new(Sequel::Model(:items)) do 363 | no_primary_key 364 | end 365 | end 366 | 367 | it "should format proper SQL" do 368 | @c.join(:atts, :item_id => :id).sql.should == \ 369 | "SELECT items.* FROM items INNER JOIN atts ON (atts.item_id = items.id)" 370 | end 371 | 372 | end 373 | 374 | describe Sequel::Model, ".all" do 375 | 376 | before(:each) do 377 | MODEL_DB.reset 378 | @c = Class.new(Sequel::Model(:items)) do 379 | no_primary_key 380 | end 381 | 382 | @c.dataset.meta_def(:all) {1234} 383 | end 384 | 385 | it "should return all records in the dataset" do 386 | @c.all.should == 1234 387 | end 388 | 389 | end 390 | 391 | class DummyModelBased < Sequel::Model(:blog) 392 | end 393 | 394 | describe Sequel::Model, "(:tablename)" do 395 | 396 | it "should allow reopening of descendant classes" do 397 | proc do 398 | eval "class DummyModelBased < Sequel::Model(:blog); end" 399 | end.should_not raise_error 400 | end 401 | 402 | end 403 | 404 | describe Sequel::Model, "A model class without a primary key" do 405 | 406 | before(:each) do 407 | MODEL_DB.reset 408 | @c = Class.new(Sequel::Model(:items)) do 409 | no_primary_key 410 | end 411 | end 412 | 413 | it "should be able to insert records without selecting them back" do 414 | i = nil 415 | proc {i = @c.create(:x => 1)}.should_not raise_error 416 | i.class.should be(@c) 417 | i.values.to_hash.should == {:x => 1} 418 | 419 | MODEL_DB.sqls.should == ['INSERT INTO items (x) VALUES (1)'] 420 | end 421 | 422 | it "should raise when deleting" do 423 | o = @c.new 424 | proc {o.delete}.should raise_error 425 | end 426 | 427 | it "should insert a record when saving" do 428 | o = @c.new(:x => 2) 429 | o.should be_new 430 | o.save 431 | MODEL_DB.sqls.should == ['INSERT INTO items (x) VALUES (2)'] 432 | end 433 | 434 | end 435 | 436 | describe Sequel::Model, "attribute accessors" do 437 | 438 | before(:each) do 439 | MODEL_DB.reset 440 | 441 | @c = Class.new(Sequel::Model(:items)) do 442 | end 443 | 444 | @c.dataset.meta_def(:columns) {[:id, :x, :y]} 445 | end 446 | 447 | it "should be created dynamically" do 448 | o = @c.new 449 | 450 | o.should_not be_respond_to(:x) 451 | o.x.should be_nil 452 | o.should be_respond_to(:x) 453 | 454 | o.should_not be_respond_to(:x=) 455 | o.x = 34 456 | o.x.should == 34 457 | o.should be_respond_to(:x=) 458 | end 459 | 460 | it "should raise for a column that doesn't exist in the dataset" do 461 | o = @c.new 462 | 463 | proc {o.x}.should_not raise_error 464 | proc {o.xx}.should raise_error(Sequel::Error) 465 | 466 | proc {o.x = 3}.should_not raise_error 467 | proc {o.yy = 4}.should raise_error(Sequel::Error) 468 | 469 | proc {o.yy?}.should raise_error(NoMethodError) 470 | end 471 | 472 | it "should not raise for a column not in the dataset, but for which there's a value" do 473 | o = @c.new 474 | 475 | proc {o.xx}.should raise_error(Sequel::Error) 476 | proc {o.yy}.should raise_error(Sequel::Error) 477 | 478 | o.values[:xx] = 123 479 | o.values[:yy] = nil 480 | 481 | proc {o.xx; o.yy}.should_not raise_error(Sequel::Error) 482 | 483 | o.xx.should == 123 484 | o.yy.should == nil 485 | 486 | proc {o.xx = 3}.should_not raise_error(Sequel::Error) 487 | end 488 | end 489 | 490 | describe Sequel::Model, ".[]" do 491 | 492 | before(:each) do 493 | MODEL_DB.reset 494 | 495 | @c = Class.new(Sequel::Model(:items)) 496 | 497 | $cache_dataset_row = {:name => 'sharon', :id => 1} 498 | @dataset = @c.dataset 499 | $sqls = [] 500 | @dataset.extend(Module.new { 501 | def fetch_rows(sql) 502 | $sqls << sql 503 | yield $cache_dataset_row 504 | end 505 | }) 506 | end 507 | 508 | it "should return the first record for the given pk" do 509 | @c[1].should be_a_kind_of(@c) 510 | $sqls.last.should == "SELECT * FROM items WHERE (id = 1) LIMIT 1" 511 | @c[9999].should be_a_kind_of(@c) 512 | $sqls.last.should == "SELECT * FROM items WHERE (id = 9999) LIMIT 1" 513 | end 514 | 515 | it "should raise for boolean argument (mistaken comparison)" do 516 | # This in order to prevent stuff like Model[:a == 'b'] 517 | proc {@c[:a == 1]}.should raise_error(Sequel::Error) 518 | proc {@c[:a != 1]}.should raise_error(Sequel::Error) 519 | end 520 | 521 | it "should work correctly for custom primary key" do 522 | @c.set_primary_key :name 523 | @c['sharon'].should be_a_kind_of(@c) 524 | $sqls.last.should == "SELECT * FROM items WHERE (name = 'sharon') LIMIT 1" 525 | end 526 | 527 | it "should work correctly for composite primary key" do 528 | @c.set_primary_key [:node_id, :kind] 529 | @c[3921, 201].should be_a_kind_of(@c) 530 | $sqls.last.should =~ \ 531 | /^SELECT \* FROM items WHERE (\(node_id = 3921\) AND \(kind = 201\))|(\(kind = 201\) AND \(node_id = 3921\)) LIMIT 1$/ 532 | end 533 | end 534 | 535 | context "Model#inspect" do 536 | setup do 537 | @o = Sequel::Model.load(:x => 333) 538 | end 539 | 540 | specify "should include the class name and the values" do 541 | @o.inspect.should == '#333}>' 542 | end 543 | end -------------------------------------------------------------------------------- /spec/record_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), "spec_helper") 2 | 3 | describe "Model#save" do 4 | 5 | before(:each) do 6 | MODEL_DB.reset 7 | 8 | @c = Class.new(Sequel::Model(:items)) do 9 | def columns 10 | [:id, :x, :y] 11 | end 12 | end 13 | end 14 | 15 | it "should insert a record for a new model instance" do 16 | o = @c.new(:x => 1) 17 | o.save 18 | 19 | MODEL_DB.sqls.first.should == "INSERT INTO items (x) VALUES (1)" 20 | end 21 | 22 | it "should update a record for an existing model instance" do 23 | o = @c.load(:id => 3, :x => 1) 24 | o.save 25 | 26 | MODEL_DB.sqls.first.should =~ 27 | /UPDATE items SET (id = 3, x = 1|x = 1, id = 3) WHERE \(id = 3\)/ 28 | end 29 | 30 | it "should update only the given columns if given" do 31 | o = @c.load(:id => 3, :x => 1, :y => nil) 32 | o.save(:y) 33 | 34 | MODEL_DB.sqls.first.should == "UPDATE items SET y = NULL WHERE (id = 3)" 35 | end 36 | 37 | it "should mark saved columns as not changed" do 38 | o = @c.new(:id => 3, :x => 1, :y => nil) 39 | o[:y] = 4 40 | o.changed_columns.should == [:y] 41 | o.save(:x) 42 | o.changed_columns.should == [:y] 43 | o.save(:y) 44 | o.changed_columns.should == [] 45 | end 46 | 47 | end 48 | 49 | describe "Model#save_changes" do 50 | 51 | before(:each) do 52 | MODEL_DB.reset 53 | 54 | @c = Class.new(Sequel::Model(:items)) do 55 | def columns 56 | [:id, :x, :y] 57 | end 58 | end 59 | end 60 | 61 | it "should do nothing if no changed columns" do 62 | o = @c.new(:id => 3, :x => 1, :y => nil) 63 | o.save_changes 64 | 65 | MODEL_DB.sqls.should be_empty 66 | 67 | o = @c.load(:id => 3, :x => 1, :y => nil) 68 | o.save_changes 69 | 70 | MODEL_DB.sqls.should be_empty 71 | end 72 | 73 | it "should update only changed columns" do 74 | o = @c.load(:id => 3, :x => 1, :y => nil) 75 | o.x = 2 76 | 77 | o.save_changes 78 | MODEL_DB.sqls.should == ["UPDATE items SET x = 2 WHERE (id = 3)"] 79 | o.save_changes 80 | o.save_changes 81 | MODEL_DB.sqls.should == ["UPDATE items SET x = 2 WHERE (id = 3)"] 82 | MODEL_DB.reset 83 | 84 | o.y = 4 85 | o.save_changes 86 | MODEL_DB.sqls.should == ["UPDATE items SET y = 4 WHERE (id = 3)"] 87 | o.save_changes 88 | o.save_changes 89 | MODEL_DB.sqls.should == ["UPDATE items SET y = 4 WHERE (id = 3)"] 90 | end 91 | 92 | end 93 | 94 | describe "Model#set" do 95 | 96 | before(:each) do 97 | MODEL_DB.reset 98 | 99 | @c = Class.new(Sequel::Model(:items)) do 100 | def columns 101 | [:id, :x, :y] 102 | end 103 | end 104 | end 105 | 106 | it "should generate an update statement" do 107 | o = @c.new(:id => 1) 108 | o.set(:x => 1) 109 | MODEL_DB.sqls.first.should == "UPDATE items SET x = 1 WHERE (id = 1)" 110 | end 111 | 112 | it "should update attribute values" do 113 | o = @c.new(:id => 1) 114 | o.x.should be_nil 115 | o.set(:x => 1) 116 | o.x.should == 1 117 | end 118 | 119 | it "should support string keys" do 120 | o = @c.new(:id => 1) 121 | o.x.should be_nil 122 | o.set('x' => 1) 123 | o.x.should == 1 124 | MODEL_DB.sqls.first.should == "UPDATE items SET x = 1 WHERE (id = 1)" 125 | end 126 | 127 | it "should be aliased by #update" do 128 | o = @c.new(:id => 1) 129 | o.update(:x => 1) 130 | MODEL_DB.sqls.first.should == "UPDATE items SET x = 1 WHERE (id = 1)" 131 | end 132 | end 133 | 134 | 135 | describe "Model#new?" do 136 | 137 | before(:each) do 138 | MODEL_DB.reset 139 | 140 | @c = Class.new(Sequel::Model(:items)) do 141 | end 142 | end 143 | 144 | it "should be true for a new instance" do 145 | n = @c.new(:x => 1) 146 | n.should be_new 147 | end 148 | 149 | it "should be false after saving" do 150 | n = @c.new(:x => 1) 151 | n.save 152 | n.should_not be_new 153 | end 154 | 155 | it "should alias new_record? to new?" do 156 | n = @c.new(:x => 1) 157 | n.should respond_to(:new_record?) 158 | n.should be_new_record 159 | n.save 160 | n.should_not be_new_record 161 | end 162 | 163 | end 164 | 165 | describe Sequel::Model, "w/ primary key" do 166 | 167 | it "should default to ':id'" do 168 | model_a = Class.new Sequel::Model 169 | model_a.primary_key.should be_equal(:id) 170 | end 171 | 172 | it "should be changed through 'set_primary_key'" do 173 | model_a = Class.new(Sequel::Model) { set_primary_key :a } 174 | model_a.primary_key.should be_equal(:a) 175 | end 176 | 177 | it "should support multi argument composite keys" do 178 | model_a = Class.new(Sequel::Model) { set_primary_key :a, :b } 179 | model_a.primary_key.should be_eql([:a, :b]) 180 | end 181 | 182 | it "should accept single argument composite keys" do 183 | model_a = Class.new(Sequel::Model) { set_primary_key [:a, :b] } 184 | model_a.primary_key.should be_eql([:a, :b]) 185 | end 186 | 187 | end 188 | 189 | describe Sequel::Model, "w/o primary key" do 190 | it "should return nil for primary key" do 191 | Class.new(Sequel::Model) { no_primary_key }.primary_key.should be_nil 192 | end 193 | 194 | it "should raise a Sequel::Error on 'this'" do 195 | instance = Class.new(Sequel::Model) { no_primary_key }.new 196 | proc { instance.this }.should raise_error(Sequel::Error) 197 | end 198 | end 199 | 200 | describe Sequel::Model, "with this" do 201 | 202 | before { @example = Class.new Sequel::Model(:examples) } 203 | 204 | it "should return a dataset identifying the record" do 205 | instance = @example.new :id => 3 206 | instance.this.sql.should be_eql("SELECT * FROM examples WHERE (id = 3) LIMIT 1") 207 | end 208 | 209 | it "should support arbitary primary keys" do 210 | @example.set_primary_key :a 211 | 212 | instance = @example.new :a => 3 213 | instance.this.sql.should be_eql("SELECT * FROM examples WHERE (a = 3) LIMIT 1") 214 | end 215 | 216 | it "should support composite primary keys" do 217 | @example.set_primary_key :x, :y 218 | instance = @example.new :x => 4, :y => 5 219 | 220 | parts = [ 221 | 'SELECT * FROM examples WHERE %s LIMIT 1', 222 | '(x = 4) AND (y = 5)', 223 | '(y = 5) AND (x = 4)' 224 | ].map { |expr| Regexp.escape expr } 225 | regexp = Regexp.new parts.first % "(?:#{parts[1]}|#{parts[2]})" 226 | 227 | instance.this.sql.should match(regexp) 228 | end 229 | 230 | end 231 | 232 | describe "Model#pk" do 233 | before(:each) do 234 | @m = Class.new(Sequel::Model) 235 | end 236 | 237 | it "should be default return the value of the :id column" do 238 | m = @m.new(:id => 111, :x => 2, :y => 3) 239 | m.pk.should == 111 240 | end 241 | 242 | it "should be return the primary key value for custom primary key" do 243 | @m.set_primary_key :x 244 | m = @m.new(:id => 111, :x => 2, :y => 3) 245 | m.pk.should == 2 246 | end 247 | 248 | it "should be return the primary key value for composite primary key" do 249 | @m.set_primary_key [:y, :x] 250 | m = @m.new(:id => 111, :x => 2, :y => 3) 251 | m.pk.should == [3, 2] 252 | end 253 | 254 | it "should raise if no primary key" do 255 | @m.set_primary_key nil 256 | m = @m.new(:id => 111, :x => 2, :y => 3) 257 | proc {m.pk}.should raise_error(Sequel::Error) 258 | 259 | @m.no_primary_key 260 | m = @m.new(:id => 111, :x => 2, :y => 3) 261 | proc {m.pk}.should raise_error(Sequel::Error) 262 | end 263 | end 264 | 265 | describe "Model#pk_hash" do 266 | before(:each) do 267 | @m = Class.new(Sequel::Model) 268 | end 269 | 270 | it "should be default return the value of the :id column" do 271 | m = @m.new(:id => 111, :x => 2, :y => 3) 272 | m.pk_hash.should == {:id => 111} 273 | end 274 | 275 | it "should be return the primary key value for custom primary key" do 276 | @m.set_primary_key :x 277 | m = @m.new(:id => 111, :x => 2, :y => 3) 278 | m.pk_hash.should == {:x => 2} 279 | end 280 | 281 | it "should be return the primary key value for composite primary key" do 282 | @m.set_primary_key [:y, :x] 283 | m = @m.new(:id => 111, :x => 2, :y => 3) 284 | m.pk_hash.should == {:y => 3, :x => 2} 285 | end 286 | 287 | it "should raise if no primary key" do 288 | @m.set_primary_key nil 289 | m = @m.new(:id => 111, :x => 2, :y => 3) 290 | proc {m.pk_hash}.should raise_error(Sequel::Error) 291 | 292 | @m.no_primary_key 293 | m = @m.new(:id => 111, :x => 2, :y => 3) 294 | proc {m.pk_hash}.should raise_error(Sequel::Error) 295 | end 296 | end 297 | 298 | describe Sequel::Model, "update_with_params" do 299 | 300 | before(:each) do 301 | MODEL_DB.reset 302 | 303 | @c = Class.new(Sequel::Model(:items)) do 304 | def self.columns; [:x, :y]; end 305 | end 306 | @o1 = @c.new 307 | @o2 = @c.load(:id => 5) 308 | end 309 | 310 | it "should filter the given params using the model columns" do 311 | @o1.update_with_params(:x => 1, :z => 2) 312 | MODEL_DB.sqls.first.should == "INSERT INTO items (x) VALUES (1)" 313 | 314 | MODEL_DB.reset 315 | @o2.update_with_params(:y => 1, :abc => 2) 316 | MODEL_DB.sqls.first.should == "UPDATE items SET y = 1 WHERE (id = 5)" 317 | end 318 | 319 | it "should be aliased by create_with" do 320 | @o1.update_with(:x => 1, :z => 2) 321 | MODEL_DB.sqls.first.should == "INSERT INTO items (x) VALUES (1)" 322 | 323 | MODEL_DB.reset 324 | @o2.update_with(:y => 1, :abc => 2) 325 | MODEL_DB.sqls.first.should == "UPDATE items SET y = 1 WHERE (id = 5)" 326 | end 327 | 328 | it "should support virtual attributes" do 329 | @c.class_def(:blah=) {|v| self.x = v} 330 | @o1.update_with(:blah => 333) 331 | MODEL_DB.sqls.first.should == "INSERT INTO items (x) VALUES (333)" 332 | end 333 | end 334 | 335 | describe Sequel::Model, "create_with_params" do 336 | 337 | before(:each) do 338 | MODEL_DB.reset 339 | 340 | @c = Class.new(Sequel::Model(:items)) do 341 | def self.columns; [:x, :y]; end 342 | end 343 | end 344 | 345 | it "should filter the given params using the model columns" do 346 | @c.create_with_params(:x => 1, :z => 2) 347 | MODEL_DB.sqls.first.should == "INSERT INTO items (x) VALUES (1)" 348 | 349 | MODEL_DB.reset 350 | @c.create_with_params(:y => 1, :abc => 2) 351 | MODEL_DB.sqls.first.should == "INSERT INTO items (y) VALUES (1)" 352 | end 353 | 354 | it "should be aliased by create_with" do 355 | @c.create_with(:x => 1, :z => 2) 356 | MODEL_DB.sqls.first.should == "INSERT INTO items (x) VALUES (1)" 357 | 358 | MODEL_DB.reset 359 | @c.create_with(:y => 1, :abc => 2) 360 | MODEL_DB.sqls.first.should == "INSERT INTO items (y) VALUES (1)" 361 | end 362 | 363 | it "should support virtual attributes" do 364 | @c.class_def(:blah=) {|v| self.x = v} 365 | o = @c.create_with(:blah => 333) 366 | MODEL_DB.sqls.first.should == "INSERT INTO items (x) VALUES (333)" 367 | end 368 | end 369 | 370 | describe Sequel::Model, "#destroy" do 371 | 372 | before(:each) do 373 | MODEL_DB.reset 374 | @model = Class.new(Sequel::Model(:items)) 375 | @model.dataset.meta_def(:delete) {MODEL_DB.execute delete_sql} 376 | 377 | @instance = @model.new(:id => 1234) 378 | #@model.stub!(:delete).and_return(:true) 379 | end 380 | 381 | it "should run within a transaction" do 382 | @model.db.should_receive(:transaction) 383 | @instance.destroy 384 | end 385 | 386 | it "should run before_destroy and after_destroy hooks" do 387 | @model.before_destroy {MODEL_DB.execute('before blah')} 388 | @model.after_destroy {MODEL_DB.execute('after blah')} 389 | @instance.destroy 390 | 391 | MODEL_DB.sqls.should == [ 392 | "before blah", 393 | "DELETE FROM items WHERE (id = 1234)", 394 | "after blah" 395 | ] 396 | end 397 | end 398 | 399 | describe Sequel::Model, "#exists?" do 400 | before(:each) do 401 | @model = Class.new(Sequel::Model(:items)) 402 | @m = @model.new 403 | end 404 | 405 | it "should returns true when #this.count > 0" do 406 | @m.this.meta_def(:count) {1} 407 | @m.exists?.should be_true 408 | end 409 | 410 | it "should return false when #this.count == 0" do 411 | @m.this.meta_def(:count) {0} 412 | @m.exists?.should be_false 413 | end 414 | end 415 | 416 | describe Sequel::Model, "#each" do 417 | setup do 418 | @model = Class.new(Sequel::Model(:items)) 419 | @m = @model.new(:a => 1, :b => 2, :id => 4444) 420 | end 421 | 422 | specify "should iterate over the values" do 423 | h = {} 424 | @m.each {|k, v| h[k] = v} 425 | h.should == {:a => 1, :b => 2, :id => 4444} 426 | end 427 | end 428 | 429 | describe Sequel::Model, "#keys" do 430 | setup do 431 | @model = Class.new(Sequel::Model(:items)) 432 | @m = @model.new(:a => 1, :b => 2, :id => 4444) 433 | end 434 | 435 | specify "should return the value keys" do 436 | @m.keys.size.should == 3 437 | @m.keys.should include(:a, :b, :id) 438 | 439 | @m = @model.new() 440 | @m.keys.should == [] 441 | end 442 | end 443 | 444 | describe Sequel::Model, "#===" do 445 | specify "should compare instances by values" do 446 | a = Sequel::Model.new(:id => 1, :x => 3) 447 | b = Sequel::Model.new(:id => 1, :x => 4) 448 | c = Sequel::Model.new(:id => 1, :x => 3) 449 | 450 | a.should_not == b 451 | a.should == c 452 | b.should_not == c 453 | end 454 | end 455 | 456 | describe Sequel::Model, "#===" do 457 | specify "should compare instances by pk only" do 458 | a = Sequel::Model.new(:id => 1, :x => 3) 459 | b = Sequel::Model.new(:id => 1, :x => 4) 460 | c = Sequel::Model.new(:id => 2, :x => 3) 461 | 462 | a.should === b 463 | a.should_not === c 464 | end 465 | end 466 | 467 | describe Sequel::Model, "#initialize" do 468 | setup do 469 | @c = Class.new(Sequel::Model) do 470 | end 471 | end 472 | 473 | specify "should accept values" do 474 | m = @c.new(:id => 1, :x => 2) 475 | m.values.should == {:id => 1, :x => 2} 476 | end 477 | 478 | specify "should accept no values" do 479 | m = @c.new 480 | m.values.should == {} 481 | end 482 | 483 | specify "should accept nil values" do 484 | m = @c.new(nil) 485 | m.values.should == {} 486 | end 487 | 488 | specify "should accept a block to execute" do 489 | m = @c.new {|o| o[:id] = 1234} 490 | m.id.should == 1234 491 | end 492 | 493 | specify "should accept virtual attributes" do 494 | @c.class_def(:blah=) {|x| @blah = x} 495 | @c.class_def(:blah) {@blah} 496 | 497 | m = @c.new(:id => 1, :x => 2, :blah => 3) 498 | m.values.should == {:id => 1, :x => 2} 499 | m.blah.should == 3 500 | end 501 | 502 | specify "should convert string keys into symbol keys" do 503 | m = @c.new('id' => 1, 'x' => 2) 504 | m.values.should == {:id => 1, :x => 2} 505 | end 506 | end 507 | 508 | describe Sequel::Model, ".create" do 509 | 510 | before(:each) do 511 | MODEL_DB.reset 512 | @c = Class.new(Sequel::Model(:items)) do 513 | def columns; [:x]; end 514 | end 515 | end 516 | 517 | it "should be able to create rows in the associated table" do 518 | o = @c.create(:x => 1) 519 | o.class.should == @c 520 | MODEL_DB.sqls.should == ['INSERT INTO items (x) VALUES (1)', "SELECT * FROM items WHERE (id IN ('INSERT INTO items (x) VALUES (1)')) LIMIT 1"] 521 | end 522 | 523 | it "should be able to create rows without any values specified" do 524 | o = @c.create 525 | o.class.should == @c 526 | MODEL_DB.sqls.should == ["INSERT INTO items DEFAULT VALUES", "SELECT * FROM items WHERE (id IN ('INSERT INTO items DEFAULT VALUES')) LIMIT 1"] 527 | end 528 | 529 | it "should accept a block and run it" do 530 | o1, o2, o3 = nil, nil, nil 531 | o = @c.create {|o3| o1 = o3; o2 = :blah; o3.x = 333} 532 | o.class.should == @c 533 | o1.should === o 534 | o3.should === o 535 | o2.should == :blah 536 | MODEL_DB.sqls.should == ["INSERT INTO items (x) VALUES (333)", "SELECT * FROM items WHERE (id IN ('INSERT INTO items (x) VALUES (333)')) LIMIT 1"] 537 | end 538 | 539 | it "should create a row for a model with custom primary key" do 540 | @c.set_primary_key :x 541 | o = @c.create(:x => 30) 542 | o.class.should == @c 543 | MODEL_DB.sqls.should == ["INSERT INTO items (x) VALUES (30)", "SELECT * FROM items WHERE (x = 30) LIMIT 1"] 544 | end 545 | end 546 | 547 | describe Sequel::Model, "#refresh" do 548 | setup do 549 | MODEL_DB.reset 550 | @c = Class.new(Sequel::Model(:items)) do 551 | def columns; [:x]; end 552 | end 553 | end 554 | 555 | specify "should reload the instance values from the database" do 556 | @m = @c.new(:id => 555) 557 | @m[:x] = 'blah' 558 | @m.this.should_receive(:first).and_return({:x => 'kaboom', :id => 555}) 559 | @m.refresh 560 | @m[:x].should == 'kaboom' 561 | end 562 | 563 | specify "should raise if the instance is not found" do 564 | @m = @c.new(:id => 555) 565 | @m.this.should_receive(:first).and_return(nil) 566 | proc {@m.refresh}.should raise_error(Sequel::Error) 567 | end 568 | 569 | specify "should be aliased by #reload" do 570 | @m = @c.new(:id => 555) 571 | @m.this.should_receive(:first).and_return({:x => 'kaboom', :id => 555}) 572 | @m.reload 573 | @m[:x].should == 'kaboom' 574 | end 575 | end --------------------------------------------------------------------------------