jferris-sconnect 0.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,45 @@
1
+ = Sconnect
2
+
3
+ Sconnect is an extension to ActiveRecord's named_scopes that allows you to combine scopes in more interesting and useful ways.
4
+
5
+ == Download
6
+
7
+ Github: http://github.com/jferris/sconnect/tree/master
8
+
9
+ == Examples
10
+
11
+ Given the following model:
12
+
13
+ class Post < ActiveRecord::Base
14
+ named_scope :published, :conditions => { :published => true }
15
+ named_scope :titled, :conditions => "title IS NOT NULL"
16
+ named_scope :from_today, lambda {
17
+ { :conditions => ['created_at >= ?', 1.day.ago] }
18
+ }
19
+ end
20
+
21
+ ActiveRecord provides scope chains:
22
+
23
+ # All published posts with titles
24
+ Post.published.titled
25
+
26
+ # All published posts from today
27
+ Post.published.from_today
28
+
29
+ Sconnect extends these scopes:
30
+
31
+ # All posts that are either published or titled
32
+ Post.published.or.titled
33
+
34
+ # All posts that are published but not created today
35
+ Post.published.not.from_today
36
+
37
+ # All posts that are either published or untitled
38
+ Post.published.or.not.titled
39
+
40
+ # All posts from today that are either published or titled
41
+ Post.published.or.titled.from_today
42
+
43
+ == Author
44
+
45
+ Sconnect was written by Joe Ferris.
data/Rakefile ADDED
@@ -0,0 +1,62 @@
1
+ require 'rubygems'
2
+ require 'rcov'
3
+ require 'spec/rake/spectask'
4
+ require 'rake/rdoctask'
5
+ require 'rake/gempackagetask'
6
+
7
+ Spec::Rake::SpecTask.new do |t|
8
+ t.libs << 'spec'
9
+ t.spec_opts << '-O spec/spec.opts'
10
+ end
11
+
12
+ desc 'Generate documentation'
13
+ Rake::RDocTask.new(:rdoc) do |rdoc|
14
+ rdoc.rdoc_dir = 'rdoc'
15
+ rdoc.title = 'Sconnect'
16
+ rdoc.options <<
17
+ '--line-numbers' <<
18
+ '--inline-source' <<
19
+ "--main" <<
20
+ "README.rdoc"
21
+ rdoc.rdoc_files.include('README.rdoc')
22
+ rdoc.rdoc_files.include('lib/**/*.rb')
23
+ end
24
+
25
+ task :default => :spec
26
+
27
+ spec = Gem::Specification.new do |s|
28
+ s.name = %q{sconnect}
29
+ s.version = "0.1"
30
+ s.summary = %q{Sconnect extends ActiveRecord's named scoped_chains to be
31
+ more useful an interesting.}
32
+ s.description = %q{Sconnect extends ActiveRecord's named_scope chains to allow
33
+ scopes to be combined inclusively, inverted, and more.}
34
+
35
+ s.files = FileList['[A-Z]*', 'lib/**/*.rb', 'spec/**/*.rb']
36
+ s.require_path = 'lib'
37
+ s.test_files = Dir[*['spec/**/*_spec.rb']]
38
+
39
+ s.has_rdoc = true
40
+ s.extra_rdoc_files = ["README.rdoc"]
41
+ s.rdoc_options = ['--line-numbers', '--inline-source', "--main", "README.rdoc"]
42
+
43
+ s.authors = ["Joe Ferris"]
44
+ s.email = %q{jferris@thoughtbot.com}
45
+
46
+ s.platform = Gem::Platform::RUBY
47
+ end
48
+
49
+ Rake::GemPackageTask.new spec do |pkg|
50
+ pkg.need_tar = true
51
+ pkg.need_zip = true
52
+ end
53
+
54
+ desc "Clean files generated by rake tasks"
55
+ task :clobber => [:clobber_rdoc, :clobber_package]
56
+
57
+ desc "Generate a gemspec file"
58
+ task :gemspec do
59
+ File.open("#{spec.name}.gemspec", 'w') do |f|
60
+ f.write spec.to_ruby
61
+ end
62
+ end
data/lib/sconnect.rb ADDED
@@ -0,0 +1,15 @@
1
+ require 'sconnect/or'
2
+ require 'sconnect/not'
3
+
4
+ #:enddoc:
5
+ module ActiveRecord
6
+ class Base
7
+ extend Sconnect::ActiveRecordClassExtensions
8
+ end
9
+
10
+ module NamedScope
11
+ class Scope
12
+ include Sconnect::ScopeExtensions
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,55 @@
1
+ module Sconnect #:nodoc:
2
+ class NotScope < ActiveRecord::NamedScope::Scope #:nodoc:
3
+
4
+ delegate :current_scoped_methods, :sanitize_sql, :to => :proxy_scope
5
+
6
+ def initialize(proxy_scope)
7
+ @proxy_scope = proxy_scope
8
+ end
9
+
10
+ def proxy_options
11
+ @proxy_options ||= invert_scope_conditions(@right_scope)
12
+ end
13
+
14
+ private
15
+
16
+ def invert_scope_conditions(scope)
17
+ scope = scope.merge(:conditions => invert_conditions(scope[:conditions]))
18
+ end
19
+
20
+ def invert_conditions(conditions)
21
+ "NOT (#{sanitize_sql(conditions)})"
22
+ end
23
+
24
+ def method_missing(method, *args, &block)
25
+ if scopes.include?(method)
26
+ right_scope = scopes[method].call(self, *args)
27
+ if @right_scope
28
+ right_scope
29
+ else
30
+ @right_scope = right_scope.proxy_options
31
+ self
32
+ end
33
+ else
34
+ super(method, *args, &block)
35
+ end
36
+ end
37
+ end
38
+
39
+ module ActiveRecordClassExtensions
40
+ # Inverts the conditions of the following scope.
41
+ #
42
+ # Examples:
43
+ # # Returns all unpublished posts
44
+ # Post.not.published
45
+ #
46
+ # # Returns all unpublished posts from today
47
+ # Post.not.published.from_today
48
+ #
49
+ # # Same as above
50
+ # Post.from_today.not.published
51
+ def not
52
+ NotScope.new(self)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,67 @@
1
+ module Sconnect #:nodoc:
2
+ class OrScope < ActiveRecord::NamedScope::Scope #:nodoc:
3
+
4
+ delegate :current_scoped_methods, :sanitize_sql, :to => :proxy_scope
5
+
6
+ def initialize(left_scope)
7
+ @left_scope = left_scope.proxy_options
8
+ @proxy_scope = left_scope.proxy_scope
9
+ end
10
+
11
+ def proxy_options
12
+ @proxy_options ||= inclusively_combine_scopes(@left_scope, @right_scope)
13
+ end
14
+
15
+ private
16
+
17
+ def inclusively_combine_scopes(left, right)
18
+ exclusively_combine_scopes(left, right).
19
+ merge(:conditions => combine_conditions(left, right))
20
+ end
21
+
22
+ def exclusively_combine_scopes(left, right)
23
+ with_scope(:find => left) do
24
+ with_scope(:find => right) do
25
+ current_scoped_methods[:find]
26
+ end
27
+ end
28
+ end
29
+
30
+ def combine_conditions(*scopes)
31
+ segments = scopes.collect do |scope|
32
+ sanitize_sql(scope[:conditions])
33
+ end
34
+ conditions = "(#{segments.join(') OR (')})"
35
+ end
36
+
37
+ def method_missing(method, *args, &block)
38
+ if scopes.include?(method)
39
+ right_scope = scopes[method].call(self, *args)
40
+ if @right_scope
41
+ right_scope
42
+ else
43
+ @right_scope = right_scope.proxy_options
44
+ self
45
+ end
46
+ else
47
+ super(method, *args, &block)
48
+ end
49
+ end
50
+ end
51
+
52
+ module ScopeExtensions
53
+ # Joins the left and right-hand scopes into an inclusive conditional clause.
54
+ #
55
+ # Examples:
56
+ #
57
+ # # Posts published or owned by the user
58
+ # Post.published.or.owned_by(@user)
59
+ #
60
+ # # Posts from today that are either published or owned by the given user
61
+ # # (note that "or" binds tighter than the implicit "and")
62
+ # Post.from_today.published.or.owned_by(@user)
63
+ def or
64
+ OrScope.new(self)
65
+ end
66
+ end
67
+ end
data/spec/not_spec.rb ADDED
@@ -0,0 +1,66 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Post" do
4
+
5
+ include ModelBuilder
6
+
7
+ before do
8
+ define_model :user
9
+ define_model :category
10
+ define_model :post, :published => :boolean,
11
+ :title => :string,
12
+ :user_id => :integer,
13
+ :category_id => :integer do
14
+ belongs_to :user
15
+ belongs_to :category
16
+ named_scope :published, :conditions => { :published => true },
17
+ :include => :user
18
+ named_scope :titled, :conditions => "title IS NOT NULL",
19
+ :include => :category
20
+ named_scope :from_today, lambda {
21
+ { :conditions => ['created_at >= ?', 1.day.ago] }
22
+ }
23
+ end
24
+ end
25
+
26
+ describe "titled but not published", :shared => true do
27
+ it "should find an unpublished, titled post" do
28
+ should include(Post.create!(:published => false, :title => 'Title'))
29
+ end
30
+
31
+ it "should not find an unpublished, titled post" do
32
+ should_not include(Post.create!(:published => true, :title => 'Title'))
33
+ end
34
+
35
+ it "should not find an published, untitled post" do
36
+ should_not include(Post.create!(:published => true, :title => nil))
37
+ end
38
+ end
39
+
40
+ describe ".not.published" do
41
+ subject { Post.not.published }
42
+
43
+ it "should find an unpublished post" do
44
+ should include(Post.create!(:published => false))
45
+ end
46
+
47
+ it "should not find an unpublished post" do
48
+ should_not include(Post.create!(:published => true))
49
+ end
50
+
51
+ it { should be_chainable }
52
+ end
53
+
54
+ describe ".not.published.titled" do
55
+ subject { Post.not.published }
56
+ it_should_behave_like "titled but not published"
57
+ it { should be_chainable }
58
+ end
59
+
60
+ describe ".titled.not.published" do
61
+ subject { Post.not.published }
62
+ it_should_behave_like "titled but not published"
63
+ it { should be_chainable }
64
+ end
65
+ end
66
+
data/spec/or_spec.rb ADDED
@@ -0,0 +1,109 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Post" do
4
+
5
+ include ModelBuilder
6
+
7
+ before do
8
+ define_model :user
9
+ define_model :category
10
+ define_model :post, :published => :boolean,
11
+ :title => :string,
12
+ :user_id => :integer,
13
+ :category_id => :integer do
14
+ belongs_to :user
15
+ belongs_to :category
16
+ named_scope :published, :conditions => { :published => true },
17
+ :include => :user
18
+ named_scope :titled, :conditions => "title IS NOT NULL",
19
+ :include => :category
20
+ named_scope :from_today, lambda {
21
+ { :conditions => ['created_at >= ?', 1.day.ago] }
22
+ }
23
+ end
24
+ end
25
+
26
+ describe "published.or.titled", :shared => true do
27
+ it "should find a published, untitled post" do
28
+ should include(Post.create!(:published => true, :title => nil))
29
+ end
30
+
31
+ it "should find an unpublished, titled post" do
32
+ should include(Post.create!(:published => false, :title => 'Title'))
33
+ end
34
+
35
+ it "should find a published, titled post" do
36
+ should include(Post.create!(:published => true, :title => 'Title'))
37
+ end
38
+
39
+ it "not should find an unpublished, untitled post" do
40
+ should_not include(Post.create!(:published => false, :title => nil))
41
+ end
42
+
43
+ it "should use non-conditional options from .published.titled" do
44
+ should include_scope_options_from(Post.published.titled)
45
+ end
46
+ end
47
+
48
+ describe "from_today", :shared => true do
49
+ it "not find a post published two days ago" do
50
+ should_not include(Post.create!(:published => true,
51
+ :title => 'Title',
52
+ :created_at => 2.days.ago))
53
+ end
54
+ end
55
+
56
+ describe ".published.or.titled" do
57
+ subject { Post.published.or.titled }
58
+ it_should_behave_like "published.or.titled"
59
+ it { should be_chainable }
60
+ end
61
+
62
+ describe ".from_today.published.or.titled" do
63
+ subject { Post.from_today.published.or.titled }
64
+
65
+ it_should_behave_like "published.or.titled"
66
+ it_should_behave_like "from_today"
67
+ it { should be_chainable }
68
+ end
69
+
70
+ describe ".published.or.titled.from_today" do
71
+ subject { Post.published.or.titled.from_today }
72
+
73
+ it_should_behave_like "published.or.titled"
74
+ it_should_behave_like "from_today"
75
+ it { should be_chainable }
76
+ end
77
+
78
+ describe ".published.or.titled.or.from_today" do
79
+ subject { Post.published.or.titled.or.from_today }
80
+
81
+ it "should find a published, untitled post from today" do
82
+ should include(Post.create!(:published => true, :title => nil))
83
+ end
84
+
85
+ it "should find an unpublished, titled post from today" do
86
+ should include(Post.create!(:published => false, :title => 'Title'))
87
+ end
88
+
89
+ it "should find a published, titled post from today" do
90
+ should include(Post.create!(:published => true, :title => 'Title'))
91
+ end
92
+
93
+ it "should find an unpublished, untitled post from today" do
94
+ should include(Post.create!(:published => false, :title => nil))
95
+ end
96
+
97
+ it "not should find an unpublished, untitled post from two days ago" do
98
+ should_not include(Post.create!(:published => false,
99
+ :title => nil,
100
+ :created_at => 2.days.ago))
101
+ end
102
+
103
+ it "should use non-conditional options from .published.titled" do
104
+ should include_scope_options_from(Post.published.titled)
105
+ end
106
+
107
+ it { should be_chainable }
108
+ end
109
+ end
@@ -0,0 +1,21 @@
1
+ require 'rubygems'
2
+ require 'spec'
3
+ require 'active_record'
4
+
5
+ SCONNECT_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
6
+
7
+ Dir["#{SCONNECT_ROOT}/spec/support/**/*.rb"].each {|file| require(file) }
8
+ $: << "#{SCONNECT_ROOT}/lib"
9
+
10
+ ActiveRecord::Base.establish_connection(
11
+ :adapter => 'sqlite3',
12
+ :database => "#{SCONNECT_ROOT}/spec/database.sqlite3"
13
+ )
14
+
15
+ ActiveRecord::Base.logger = Logger.new("#{SCONNECT_ROOT}/spec/spec.log")
16
+
17
+ Spec::Runner.configure do |config|
18
+ config.include Matchers
19
+ end
20
+
21
+ require 'sconnect'
@@ -0,0 +1,18 @@
1
+ module ActiveRecord
2
+ class Base
3
+ # This allows us to look at the options at the end of a chain of scopes.
4
+ def self.scope_options
5
+ current_scoped_methods[:find]
6
+ end
7
+ end
8
+
9
+ module NamedScope
10
+ class Scope
11
+ # This allows Scope objects to be used as matchers. Without this hack, a
12
+ # Scope will be converted to an array when #should is called, so the
13
+ # matcher will always receive an array. This prevents #should from being
14
+ # delegated to the Scope's proxy.
15
+ remove_method :should
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,67 @@
1
+ module ModelBuilder
2
+ def self.included(example_group)
3
+ example_group.class_eval do
4
+ before do
5
+ @defined_constants = []
6
+ @created_tables = []
7
+ end
8
+
9
+ after do
10
+ @defined_constants.each do |class_name|
11
+ Object.send(:remove_const, class_name)
12
+ end
13
+
14
+ @created_tables.each do |table_name|
15
+ ActiveRecord::Base.
16
+ connection.
17
+ execute("DROP TABLE IF EXISTS #{table_name}")
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ def create_table(table_name, &block)
24
+ connection = ActiveRecord::Base.connection
25
+
26
+ begin
27
+ connection.execute("DROP TABLE IF EXISTS #{table_name}")
28
+ connection.create_table(table_name, &block)
29
+ @created_tables << table_name
30
+ connection
31
+ rescue Exception => exception
32
+ connection.execute("DROP TABLE IF EXISTS #{table_name}")
33
+ raise exception
34
+ end
35
+ end
36
+
37
+ def define_constant(class_name, base, &block)
38
+ class_name = class_name.to_s.camelize
39
+
40
+ klass = Class.new(base)
41
+ Object.const_set(class_name, klass)
42
+
43
+ klass.class_eval(&block) if block_given?
44
+
45
+ @defined_constants << class_name
46
+
47
+ klass
48
+ end
49
+
50
+ def define_model_class(class_name, &block)
51
+ define_constant(class_name, ActiveRecord::Base, &block)
52
+ end
53
+
54
+ def define_model(name, columns = {}, &block)
55
+ class_name = name.to_s.pluralize.classify
56
+ table_name = class_name.tableize
57
+
58
+ create_table(table_name) do |table|
59
+ columns.each do |name, type|
60
+ table.column name, type
61
+ end
62
+ table.timestamps
63
+ end
64
+
65
+ define_model_class(class_name, &block)
66
+ end
67
+ end
@@ -0,0 +1,8 @@
1
+ module Matchers
2
+ def be_chainable
3
+ simple_matcher do |given, matcher|
4
+ matcher.description = "return a chainable named scope"
5
+ given.class.instance_methods.include?('proxy_scope')
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,38 @@
1
+ module Matchers
2
+
3
+ class ScopeOptionsMatcher
4
+ def initialize(expected)
5
+ @expected_scope_options = scope_options(expected)
6
+ end
7
+
8
+ def matches?(target)
9
+ target_scope_options = scope_options(target)
10
+ @expected_scope_options.detect do |@key, expected_values|
11
+ expected_values = [expected_values] unless expected_values.is_a?(Array)
12
+ @target_values = target_scope_options[@key]
13
+ @target_values = [@target_values] unless @target_values.is_a?(Array)
14
+ @missing =
15
+ expected_values.detect {|value| !@target_values.include?(value) }
16
+ end
17
+
18
+ @missing.nil?
19
+ end
20
+
21
+ def failure_message
22
+ "Missing value #{@missing.inspect} for #{@key.inspect}" <<
23
+ " (found values: #{@target_values.inspect})"
24
+ end
25
+
26
+ private
27
+
28
+ def scope_options(scope)
29
+ scope_options = scope.scope_options
30
+ scope_options.delete(:conditions)
31
+ scope_options
32
+ end
33
+ end
34
+
35
+ def include_scope_options_from(scope)
36
+ ScopeOptionsMatcher.new(scope)
37
+ end
38
+ end
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jferris-sconnect
3
+ version: !ruby/object:Gem::Version
4
+ version: "0.1"
5
+ platform: ruby
6
+ authors:
7
+ - Joe Ferris
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-02-21 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: Sconnect extends ActiveRecord's named_scope chains to allow scopes to be combined inclusively, inverted, and more.
17
+ email: jferris@thoughtbot.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - README.rdoc
24
+ files:
25
+ - Rakefile
26
+ - README.rdoc
27
+ - lib/sconnect/not.rb
28
+ - lib/sconnect/or.rb
29
+ - lib/sconnect.rb
30
+ - spec/not_spec.rb
31
+ - spec/or_spec.rb
32
+ - spec/spec_helper.rb
33
+ - spec/support/active_record_extensions.rb
34
+ - spec/support/model_builder.rb
35
+ - spec/support/scope_matcher.rb
36
+ - spec/support/scope_options_matcher.rb
37
+ has_rdoc: true
38
+ homepage:
39
+ post_install_message:
40
+ rdoc_options:
41
+ - --line-numbers
42
+ - --inline-source
43
+ - --main
44
+ - README.rdoc
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: "0"
52
+ version:
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: "0"
58
+ version:
59
+ requirements: []
60
+
61
+ rubyforge_project:
62
+ rubygems_version: 1.2.0
63
+ signing_key:
64
+ specification_version: 2
65
+ summary: Sconnect extends ActiveRecord's named scoped_chains to be more useful an interesting.
66
+ test_files:
67
+ - spec/not_spec.rb
68
+ - spec/or_spec.rb