jferris-sconnect 0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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