Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

first commit

  • Loading branch information...
commit bb7696c6db910e421754f85081ee5eb60b61b978 0 parents
@txus txus authored
7 .gitignore
@@ -0,0 +1,7 @@
+*.gem
+.bundle
+Gemfile.lock
+pkg/*
+graph.png
+.yardoc/*
+doc/*
2  .rspec
@@ -0,0 +1,2 @@
+--color
+--format documentation
1  .rvmrc
@@ -0,0 +1 @@
+rvm --create use ruby-1.9.2@resort
3  Gemfile
@@ -0,0 +1,3 @@
+source "http://rubygems.org"
+
+gemspec
47 Rakefile
@@ -0,0 +1,47 @@
+require 'bundler'
+Bundler::GemHelper.install_tasks
+
+require 'rspec/core/rake_task'
+desc "Run resort specs"
+RSpec::Core::RakeTask.new
+
+require 'yard'
+YARD::Rake::YardocTask.new(:docs) do |t|
+ t.files = ['lib/**/*.rb']
+ t.options = ['-m', 'markdown', '--no-private', '-r', 'Readme.md', '--title', 'Resort documentation']
+end
+
+site = 'doc'
+source_branch = 'master'
+deploy_branch = 'gh-pages'
+
+desc "generate and deploy documentation website to github pages"
+multitask :pages do
+ puts ">>> Deploying #{deploy_branch} branch to Github Pages <<<"
+ require 'git'
+ repo = Git.open('.')
+ puts "\n>>> Checking out #{deploy_branch} branch <<<\n"
+ repo.branch("#{deploy_branch}").checkout
+ (Dir["*"] - [site]).each { |f| rm_rf(f) }
+ Dir["#{site}/*"].each {|f| mv(f, "./")}
+ rm_rf(site)
+ puts "\n>>> Moving generated site files <<<\n"
+ Dir["**/*"].each {|f| repo.add(f) }
+ repo.status.deleted.each {|f, s| repo.remove(f)}
+ puts "\n>>> Commiting: Site updated at #{Time.now.utc} <<<\n"
+ message = ENV["MESSAGE"] || "Site updated at #{Time.now.utc}"
+ repo.commit(message)
+ puts "\n>>> Pushing generated site to #{deploy_branch} branch <<<\n"
+ repo.push
+ puts "\n>>> Github Pages deploy complete <<<\n"
+ repo.branch("#{source_branch}").checkout
+end
+
+task :doc => [:docs]
+
+desc "Generate and open class diagram (needs Graphviz installed)"
+task :graph do |t|
+ `bundle exec yard graph -d --full --no-private | dot -Tpng -o graph.png && open graph.png`
+end
+
+task :default => [:spec]
92 Readme.md
@@ -0,0 +1,92 @@
+#resort
+
+Resort provides sorting capabilities to your Rails 3 models.
+
+##Install
+
+ $ gem install resort
+
+Or in your Gemfile:
+
+ gem 'resort'
+
+##Rationale
+
+Most other sorting plugins work with an absolute `position` attribute that sets
+the _weight_ of a given element within a tree. This field has no semantic sense,
+since "84" by itself gives you absolutely no information about an element's
+position or its relations with other elements of the tree.
+
+Resort is implemented quite like a [binary tree](http://en.wikipedia.org/wiki/Binary_tree),
+rather than relying on absolute position values. This way, every model
+references a `next` and a `previous`, which seems a bit more sensible :)
+
+##Usage
+
+You must add two fields (`next_id` and `first`) to your model's table:
+
+ class AddResortFieldsToProducts < ActiveRecord::Migration
+ def self.up
+ add_column :products, :next_id, :integer
+ add_column :products, :first, :boolean
+ end
+
+ def self.down
+ remove_column :products, :next_id
+ remove_column :products, :first
+ end
+ end
+
+Then in your Product model:
+
+ class Product < ActiveRecord::Base
+ resort!
+ end
+
+**NOTE**: By default, Resort will treat _all products_ as a single big tree.
+If you wanted to limit the tree scope, i.e. treating every ProductLine as a
+separate tree of sortable products, you must override the `siblings` method:
+
+ class Product < ActiveRecord::Base
+ resort!
+
+ def siblings
+ # Tree contains only products from my own product line
+ self.product_line.products
+ end
+ end
+
+###API
+
+Every time a product is created, it will be appended after the last element.
+
+Moreover, now a `product` responds to the following methods:
+
+* `first?` &mdash; Returns true if the element is the first of the tree.
+* `append_to(other_element)` &mdash; Appends the element _after_ another element.
+
+And the class Product has a new scope named `ordered` that returns the
+products in order.
+
+##Under the hood
+
+Run the test suite by typing:
+
+ rake spec
+
+You can also build the documentation with the following command:
+
+ rake docs
+
+## Note on Patches/Pull Requests
+
+* Fork the project.
+* Make your feature addition or bug fix.
+* Add tests for it. This is important so I don't break it in a
+ future version unintentionally.
+* Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
+* Send us a pull request. Bonus points for topic branches.
+
+## Copyright
+
+Copyright (c) 2011 Codegram. See LICENSE for details.
204 lib/resort.rb
@@ -0,0 +1,204 @@
+# # Resort
+#
+# A tool that allows any ActiveRecord model to be sorted.
+#
+# Unlike most Rails sorting plugins (acts_as_list, etc), Resort is based
+# on linked lists rather than absolute position fields.
+#
+# @example Using Resort in an ActiveRecord model
+# # In the migration
+# create_table :products do |t|
+# t.text :name
+# t.references :next
+# t.boolean :first
+# end
+#
+# # Model
+# class Product < ActiveRecord::Base
+# resort!
+#
+# # A sortable model must implement #siblings method, which should
+# # return and ActiveRecord::Relation with all the models to be
+# # considered as `peers` in the list representing the sorted
+# # products, i.e. its siblings.
+# def siblings
+# self.class.scoped
+# end
+# end
+#
+# product = Product.create(:name => 'Bread')
+# product.first? # => true
+#
+# another_product = Product.create(:name => 'Milk')
+# yet_another_product = Product.create(:name => 'Salami')
+#
+# yet_another_product.append_to(product)
+#
+# Product.ordered.map(&:name)
+# # => ['Bread', 'Salami', 'Milk']
+module Resort
+ # The module encapsulating all the Resort functionality.
+ #
+ # @todo Refactor into a more OO solution, maybe implementing a LinkedList
+ # object.
+ module Sortable
+ class << self
+ # When included, extends the includer with {ClassMethods}, and includes
+ # {InstanceMethods} in it.
+ #
+ # It also establishes the required relationships. It is necessary that
+ # the includer table has the following database columns:
+ #
+ # t.references :next
+ # t.boolean :first
+ #
+ # @param [ActiveRecord::Base] base the includer `ActiveRecord` model.
+ def included(base)
+ base.extend ClassMethods
+ base.send :include, InstanceMethods
+
+ base.has_one :previous, :class_name => base.name, :foreign_key => 'next_id', :inverse_of => :next
+ base.belongs_to :next, :class_name => base.name, :inverse_of => :previous
+
+ base.after_create :include_in_list!
+ base.after_destroy :delete_from_list
+ end
+ end
+
+ # Class methods to be used from the model class.
+ module ClassMethods
+ # Returns the first element of the list.
+ #
+ # @return [ActiveRecord::Base] the first element of the list.
+ def first_in_order
+ where(:first => true).first
+ end
+
+ # Returns eager-loaded Components in order.
+ #
+ # OPTIMIZE: Avoid creating as many hashes.
+ # @return [Array<ActiveRecord::Base>] the ordered elements
+ def ordered
+ ordered_elements = []
+ elements = {}
+
+ scoped.each do |element|
+ if element.first?
+ ordered_elements << element
+ else
+ elements[element.id] = element
+ end
+ end
+
+ elements.length.times do
+ ordered_elements << elements[ordered_elements.last.next_id]
+ end
+ ordered_elements
+ end
+ end
+
+ # Instance methods to use.
+ module InstanceMethods
+
+ # Default definition of siblings, i.e. every instance of the model.
+ #
+ # Can be overriden to specify a different scope for the siblings.
+ # For example, if we wanted to limit a products tree inside a ProductLine
+ # scope, we would do the following:
+ #
+ # class Product < ActiveRecord::Base
+ # belongs_to :product_line
+ #
+ # resort!
+ #
+ # def siblings
+ # self.product_line.products
+ # end
+ #
+ # This way, every product line is an independent tree of sortable
+ # products.
+ #
+ # @return [ActiveRecord::Relation] the element's siblings relation.
+ def siblings
+ self.class.scoped
+ end
+ # Includes the object in the linked list.
+ #
+ # If there are no other objects, it prepends the object so that it is
+ # in the first position. Otherwise, it appends it to the end of the
+ # empty list.
+ def include_in_list!
+ _siblings.count > 0 ? push\
+ : prepend
+ end
+
+ # Puts the object in the first position of the list.
+ def prepend
+ return if first?
+
+ if _siblings.count > 0
+ delete_from_list
+ _siblings.where(:first => true).first.append_to(self)
+ end
+
+ self.update_attribute(:first, true)
+ end
+
+ # Puts the object in the last position of the list.
+ def push
+ return if last?
+ last_element = _siblings.where(:next_id => nil).first
+ self.append_to(last_element)
+ end
+
+ # Puts the object right after another object in the list.
+ def append_to(another)
+ if self.next
+ delete_from_list
+ elsif last?
+ self.previous.update_attribute(:next_id, nil)
+ self.previous = nil
+ end
+
+ self.update_attribute(:next_id, another.next_id)
+ another.update_attribute(:next_id, self.id)
+ end
+
+ private
+
+ def delete_from_list
+ if first? && self.next
+ self.update_attribute(:first, nil) unless frozen?
+ self.next.first = true
+ self.next.previous = nil
+ self.next.save!
+ elsif self.previous
+ previous.next = self.next
+ previous.save!
+ self.update_attribute(:next_id, nil) unless frozen?
+ end
+ end
+
+ def last?
+ self.previous && !self.next
+ end
+
+ def _siblings
+ table = self.class.arel_table
+ siblings.where(table[:id].not_eq(self.id))
+ end
+ end
+ end
+ # Helper class methods to be injected into ActiveRecord::Base class.
+ # They will be available to every model.
+ module ClassMethods
+ # Helper class method to include Resort::Sortable in an ActiveRecord
+ # model.
+ def resort!
+ include Sortable
+ end
+ end
+end
+
+require 'active_record' unless defined?(ActiveRecord)
+ActiveRecord::Base.extend Resort::ClassMethods
4 lib/resort/version.rb
@@ -0,0 +1,4 @@
+module Resort
+ # Resort's version number
+ VERSION = "0.0.1"
+end
27 resort.gemspec
@@ -0,0 +1,27 @@
+# -*- encoding: utf-8 -*-
+$:.push File.expand_path("../lib", __FILE__)
+require "resort/version"
+
+Gem::Specification.new do |s|
+ s.name = "resort"
+ s.version = Resort::VERSION
+ s.platform = Gem::Platform::RUBY
+ s.authors = ["Oriol Gual", "Josep M. Bach", "Josep Jaume Rey"]
+ s.email = ["info@codegram.com"]
+ s.homepage = "http://codegram.github.com/resort"
+ s.summary = %q{Positionless model sorting for Rails 3.}
+ s.description = %q{Positionless model sorting for Rails 3.}
+
+ s.rubyforge_project = "resort"
+
+ s.add_runtime_dependency 'activerecord', '~> 3.0.5'
+ s.add_development_dependency 'sqlite3'
+ s.add_development_dependency 'rspec', '~> 2.5.0'
+ s.add_development_dependency 'yard'
+ s.add_development_dependency 'bluecloth'
+
+ s.files = `git ls-files`.split("\n")
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
+ s.require_paths = ["lib"]
+end
283 spec/resort_spec.rb
@@ -0,0 +1,283 @@
+require 'spec_helper'
+
+class Article < ActiveRecord::Base
+ resort!
+end
+
+module Resort
+ describe Sortable do
+
+ subject { Article.new }
+
+ context 'when included' do
+ it 'creates previous and next relationships' do
+ subject.should respond_to(:previous, :next)
+ end
+
+ it 'includes base with InstanceMethods' do
+ subject.class.ancestors.should include(Sortable::InstanceMethods)
+ end
+ it 'extend base with ClassMethods' do
+ (class << subject.class; self; end).ancestors.should include(Sortable::ClassMethods)
+ end
+ it 'defines a siblings method' do
+ subject.class.instance_methods.should include(:siblings)
+ end
+ end
+
+ describe 'ClassMethods' do
+ describe "#first_in_order" do
+ it 'returns the first element of the list' do
+ first = double :article
+ Article.should_receive(:where).with(:first => true).and_return [first]
+
+ Article.first_in_order
+ end
+ end
+ describe "#ordered" do
+ before do
+ Article.destroy_all
+
+ 4.times do |i|
+ Article.create(:name => i.to_s)
+ end
+
+ @article1 = Article.find_by_name('0')
+ @article2 = Article.find_by_name('1')
+ @article3 = Article.find_by_name('2')
+ @article4 = Article.find_by_name('3')
+ end
+ it 'returns the first element of the list' do
+ Article.ordered.should == [@article1, @article2, @article3, @article4]
+ end
+ after do
+ Article.destroy_all
+ end
+ end
+ end
+
+ describe "after create" do
+ context 'when there are no siblings' do
+ it 'prepends the element' do
+ article = Article.create(:name => 'first!')
+
+ article.should be_first
+ article.next.should be_nil
+ article.previous.should be_nil
+ end
+ end
+ context 'otherwise' do
+ it 'appends the element' do
+ Article.create(:name => "1")
+ Article.create(:name => 'last!')
+
+ article = Article.find_by_name('last!')
+
+ article.should be_last
+ article.previous.name.should == '1'
+ end
+ end
+ after do
+ Article.destroy_all
+ end
+ end
+
+ describe "after destroy" do
+ context 'when the element is the first' do
+ it 'removes the element' do
+ article = Article.create(:name => 'first!')
+ article2 = Article.create(:name => 'second!')
+ article3 = Article.create(:name => 'last!')
+
+ article = Article.find_by_name('first!')
+ article.destroy
+
+ article2 = Article.find_by_name('second!')
+
+ article2.should be_first
+ article2.previous.should be_nil
+ end
+ end
+ context 'when the element is in the middle' do
+ it 'removes the element' do
+ article = Article.create(:name => 'first!')
+ article2 = Article.create(:name => 'second!')
+ article3 = Article.create(:name => 'last!')
+
+ article = Article.find_by_name('first!')
+
+ article2 = Article.find_by_name('second!')
+ article2.destroy
+
+ article = Article.find_by_name('first!')
+ article3 = Article.find_by_name('last!')
+
+ article.should be_first
+ article.next.name.should == 'last!'
+ article3.previous.name.should == 'first!'
+ end
+ end
+ context 'when the element is last' do
+ it 'removes the element' do
+ article = Article.create(:name => 'first!')
+ article2 = Article.create(:name => 'second!')
+ article3 = Article.create(:name => 'last!')
+
+ article3.destroy
+
+ article2.next.should be_nil
+ end
+ end
+ after do
+ Article.destroy_all
+ end
+ end
+
+ describe 'InstanceMethods' do
+ before do
+ Article.destroy_all
+ Article.create(:name => "1")
+ Article.create(:name => "2")
+ Article.create(:name => "3")
+ Article.create(:name => "4")
+
+ @article1 = Article.find_by_name('1')
+ @article2 = Article.find_by_name('2')
+ @article3 = Article.find_by_name('3')
+ @article4 = Article.find_by_name('4')
+ end
+
+ describe "#push" do
+ it "appends the element to the list" do
+ @article1.push
+
+ article1 = Article.find_by_name('1')
+ article1.previous.should == @article4
+ article1.next.should be_nil
+ end
+ context 'when the article is already last' do
+ it 'does nothing' do
+ @article4.push
+
+ @article4.previous.name.should == '3'
+ @article4.next.should be_nil
+ end
+ end
+ end
+
+ describe "#prepend" do
+ it "prepends the element" do
+ @article3.prepend
+
+ article3 = Article.find_by_name('3')
+
+ article3.should be_first
+ article3.previous.should be_nil
+ article3.next.name.should == '1'
+ end
+ context 'when the article is already first' do
+ it 'does nothing' do
+ @article1.prepend
+
+ @article1.previous.should be_nil
+ @article1.next.name.should == '2'
+ end
+ end
+ end
+
+ describe "#append_to" do
+ context 'appending 1 after 2' do
+ it "appends the element after another element" do
+ @article1.append_to(@article2)
+
+ article2 = Article.find_by_name('2')
+ article2.next.name.should == '1'
+
+ article1 = Article.find_by_name('1')
+ article1.next.name.should == '3'
+ article1.previous.name.should == '2'
+ @article3.previous.name.should == '1'
+
+ article2.should be_first
+ end
+ end
+ context 'appending 1 after 3' do
+ it "appends the element after another element" do
+ @article1.append_to(@article3)
+
+ article2 = Article.find_by_name('2')
+ article2.should be_first
+ article2.previous.should be_nil
+
+ article1 = Article.find_by_name('1')
+ article1.should_not be_first
+ article1.previous.name.should == '3'
+ article1.next.name.should == '4'
+
+ @article3.next.name.should == '1'
+
+ @article4.previous.name.should == '1'
+ end
+ end
+ context 'appending 2 after 3' do
+ it "appends the element after another element" do
+ @article2.append_to(@article3)
+
+ article1 = Article.find_by_name('1')
+ article1.next.name.should == '3'
+
+ article2 = Article.find_by_name('2')
+ article2.previous.name.should == '3'
+ article2.next.name.should == '4'
+
+ @article3.previous.name.should == '1'
+ @article3.next.name.should == '2'
+
+ @article4.previous.name.should == '2'
+ end
+ end
+ context 'appending 2 after 4' do
+ it "appends the element after another element" do
+ @article2.append_to(@article4)
+
+ article1 = Article.find_by_name('1')
+ article3 = Article.find_by_name('3')
+
+ article1.next.name.should == '3'
+ article3.previous.name.should == '1'
+
+ article2 = Article.find_by_name('2')
+ article2.previous.name.should == '4'
+ article2.should be_last
+
+ @article4.next.name.should == '2'
+ end
+ end
+ context 'appending 4 after 2' do
+ it "appends the element after another element" do
+ @article4.append_to(@article2)
+
+ article3 = Article.find_by_name('3')
+ article3.next.should be_nil
+ article3.previous.name.should == '4'
+
+ article4 = Article.find_by_name('4')
+ @article2.next.name.should == '4'
+ article4.previous.name.should == '2'
+ article4.next.name.should == '3'
+ end
+ end
+
+ context 'when the article is already after the other element' do
+ it 'does nothing' do
+ @article2.append_to(@article1)
+
+ @article1.next.name.should == '2'
+ @article2.previous.name.should == '1'
+ end
+ end
+ end
+ end
+
+ end
+end
19 spec/spec_helper.rb
@@ -0,0 +1,19 @@
+require 'rspec'
+require 'resort'
+
+ActiveRecord::Base.establish_connection(
+ :adapter => 'sqlite3',
+ :database => ':memory:'
+)
+
+ActiveRecord::Schema.define do
+ create_table :articles do |t|
+ t.string :name
+ t.integer :price
+
+ t.boolean :first
+ t.references :next
+
+ t.timestamps
+ end
+end
Please sign in to comment.
Something went wrong with that request. Please try again.