require 'dm-core'
require 'dm-adjust'
module DataMapper
module Is
# = dm-is-list
#
# DataMapper plugin for creating and organizing lists.
#
# == Installation
#
# === Stable
#
# Install the +dm-is-list+ gem using rubygems.
#
# $ gem install dm-is-list
#
# === Edge
#
# Download or clone +dm-is-list+ from Github[http://github.com/datamapper/dm-is-list/].
#
# $ cd /path/to/dm-is-list
#
# $ rake install
#
# == Getting started
#
# First of all, for a better understanding of this gem, make sure you study the 'dm-is-list/spec/integration/list_spec.rb' file.
#
# ----
#
# Require +dm-is-list+ in your app.
#
# require 'dm-core' # must be required first
# require 'dm-is-list'
#
#
# Lets say we have a User class, and we want to give users the possibility of
# having their own todo-lists.
#
#
# class User
# include DataMapper::Resource
#
# property :id, Serial
# property :name, String
#
# has n, :todos
# end
#
# class Todo
# include DataMapper::Resource
#
# property :id, Serial
# property :title, String
# property :done, DateTime
#
# belongs_to :user
#
# # here we define that this should be a list, scoped on :user_id
# is :list, :scope => [:user_id]
# end
#
# Once we have our Users and Lists, we might want to work with...
#
# == Movements of list items
#
# Any list item can be moved around within the same list easily through the move method.
#
#
# === move( vector )
#
# There are number of convenient vectors that help you move items around within the list.
#
# item = Todo.get(1)
# other = Todo.get(2)
#
# item.move(:highest) # moves to top of list.
# item.move(:lowest) # moves to bottom of list.
# item.move(:top) # moves to top of list.
# item.move(:bottom) # moves to bottom of list.
# item.move(:up) # moves one up (:higher and :up is the same) within the scope.
# item.move(:down) # moves one up (:lower and :down is the same) within the scope.
# item.move(:to => position) # moves item to a specific position.
# item.move(:above => other) # moves item above the other item.*
# item.move(:below => other) # moves item above the other item.*
#
# # * won't move if the other item is in another scope. (should this be enabled?)
#
# The list will act as intelligently as possible and keep positions in a logical running order.
#
#
# === move( Integer )
#
# NOTE! VERY IMPORTANT!
#
# If you set the position manually, and then save, the list will NOT reorganize itself.
#
# item.position = 3 # setting position manually
# item.save # the item will now have position 3, but the list may have two items with the same position.
#
# # alternatively
# item.update(:position => 3) # sets the position manually, but does not reorganize the list positions.
#
#
# You should therefore always use the item.move(N) syntax instead.
#
# item.move(3) # does the same as above, but in one call AND *reorganizes* the list.
#
#
#
# Hold On!
#
# dm-is-list used to work with item.position = 1 type syntax. Why this change?
#
# The main reason behind this change was that the previous version of dm-is-list created a LOT of
# extra SQL queries in order to support the manual updating of position, and as a result had a quite a few bugs/issues,
# which have been fixed in this version.
#
# The other reason is that I couldn't work out how to keep the functionality without adding the extra queries. But perhaps you can ?
#
#
#
# See "Batch Changing Positions" below for information on how to change the positions on a whole list.
#
# == Movements between scopes
#
# When you move items between scopes, the list will try to work with your intentions.
#
#
# Move the item from list to new list and add the item to the bottom of that list.
#
# item.user_id # => 1
# item.move_to_list(10) # => the scope id ie User.get(10).id
#
# # results in...
# item.user_id # => 10
# item.position # => < bottom of the list >
#
#
# Move the item from list to new list and add at the position given.
#
# item.user_id # => 1
# item.move_to_list(10, 2) # => the scope id ie User.get(10).id, position => 2
#
# # results in...
# item.user_id # => 10
# item.position # => 2
#
#
# == Batch Changing Positions
#
# A common scenario when working with lists is the sorting of a whole list via something like JQuery's sortable() functionality.
#
# (Think re-arranging the order of Todo's according to priority or something similar)
#
#
# === Optimum scenario
#
# The most SQL query efficient way of changing the positions is:
#
#
# sort_order = [5,4,3,2,1] # list from AJAX request..
#
# items = Todo.all(:user => @u1) # loads all 5 items in the list
#
# items.each{ |item| item.update(:position => sort_order.index(item.id) + 1) } # remember the +1 since array's are indexed from 0
#
#
# The above code will result in something like these queries.
#
# # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position"
# # UPDATE "todos" SET "position" = 5 WHERE "id" = 1
# # UPDATE "todos" SET "position" = 4 WHERE "id" = 2
# # UPDATE "todos" SET "position" = 2 WHERE "id" = 4
# # UPDATE "todos" SET "position" = 1 WHERE "id" = 5
#
# Remember! Your sort order list has to be the same length as the found items in the list, or your loop will fail.
#
#
# === Wasteful scenario
#
# You can also use this version, but it will create upto 5 times as many SQL queries. :(
#
#
# sort_order = ['5','4','3','2','1'] # list from AJAX request..
#
# items = Todo.all(:user => @u1) # loads all 5 items in the list
#
# items.each{ |item| item.move(sort_order.index(item.id).to_i + 1) } # remember the +1 since array's are indexed from 0
#
# The above code will result in something like these queries:
#
# # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position"
#
# # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position" DESC LIMIT 1
# # SELECT "id" FROM "todos" WHERE "user_id" = 1 AND "id" IN (1, 2, 3, 4, 5) AND "position" BETWEEN 1 AND 5 ORDER BY "position"
# # UPDATE "todos" SET "position" = "position" + -1 WHERE "user_id" = 1 AND "position" BETWEEN 1 AND 5
# # SELECT "id", "position" FROM "todos" WHERE "id" IN (1, 2, 3, 4, 5) ORDER BY "id"
# # UPDATE "todos" SET "position" = 5 WHERE "id" = 1
#
# # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position" DESC LIMIT 1
# # SELECT "id" FROM "todos" WHERE "user_id" = 1 AND "id" IN (1, 2, 3, 4, 5) AND "position" BETWEEN 1 AND 4 ORDER BY "position"
# # UPDATE "todos" SET "position" = "position" + -1 WHERE "user_id" = 1 AND "position" BETWEEN 1 AND 4
# # SELECT "id", "position" FROM "todos" WHERE "id" IN (2, 3, 4, 5) ORDER BY "id"
# # UPDATE "todos" SET "position" = 4 WHERE "id" = 2
#
# # ...
#
# As you can see it will also do the job, but will be more expensive.
#
#
# == RTFM
#
# As I said above, for a better understanding of this gem/plugin, make sure you study the 'dm-is-list/spec/integration/list_spec.rb' tests.
#
#
# == Errors / Bugs
#
# If something is not behaving intuitively, it is a bug, and should be reported.
# Report it here: http://datamapper.lighthouseapp.com/
#
module List
##
# method for making your model a list.
# TODO:: this explanation is confusing. Need to translate into literal code
#
# it will define a :position property if it does not exist, so be sure to have a
# position-column in your database (will be added automatically on auto_migrate)
# if the column has a different name, simply make a :position-property and set a
# custom :field
#
# @example [Usage]
# is :list # put this in your model to make it act as a list.
# is :list, :scope => :user_id # you can also define scopes
# is :list, :scope => [ :user_id, :context_id ] # also works with multiple params
#
# @param options a hash of options
#
# @option :scope an array of attributes that should be used to scope lists
#
# @api public
def is_list(options={})
options = { :scope => [], :first => 1 }.merge(options)
# coerce the scope into an Array
options[:scope] = Array(options[:scope])
extend DataMapper::Is::List::ClassMethods
include DataMapper::Is::List::InstanceMethods
unless properties.any? { |p| p.name == :position && p.primitive == Integer }
property :position, Integer
end
@list_options = options
before :create do
# if a position has been set before save, then insert it at the position and
# move the other items in the list accordingly, else if no position has been set
# then set position to bottom of list
__send__(:move_without_saving, position || :lowest)
# on create, set moved to false so we can move the list item after creating it
# self.moved = false
end
before :update do
# a (new) position has been set => move item to this position (only if position has been set manually)
# the scope has changed => detach from old list, and possibly move into position
# the scope and position has changed => detach from old, move to pos in new
# if the scope has changed, we need to detach our item from the old list
if list_scope != original_list_scope
newpos = position
detach(original_list_scope) # removing from old list
__send__(:move_without_saving, newpos || :lowest) # moving to pos or bottom of new list
end
# NOTE:: uncommenting the following creates a large number of extra un-wanted SQL queries
# hence the commenting out of it.
# if attribute_dirty?(:position) && !moved
# __send__(:move_without_saving, position)
# end
# # on update, clean moved to prepare for the next change
# self.moved = false
end
before :destroy do
detach
end
# we need to make sure that STI-models will inherit the list_scope.
after_class_method :inherited do |retval, target|
target.instance_variable_set(:@list_options, @list_options.dup)
end
end # is_list
module ClassMethods
attr_reader :list_options
##
# use this function to repair / build your lists.
#
# @example [Usage]
# MyModel.repair_list # repairs the list, given that lists are not scoped
# MyModel.repair_list(:user_id => 1) # fixes the list for user 1, given that the scope is [:user_id]
#
# @param scope [Hash]
#
# @api public
def repair_list(scope = {})
return false unless scope.keys.all?{ |s| list_options[:scope].include?(s) || s == :order }
retval = true
all({ :order => [ :position.asc ] | default_order }.merge(scope)).each_with_index do |item, index|
retval &= item.update(:position => index.succ)
end
retval
end
end
module InstanceMethods
# @api semipublic
attr_accessor :moved
##
# returns the scope of the current list item
#
# @return ...?
#
# @example [Usage]
# Todo.get(2).list_scope => { :user_id => 1 }
#
#
# @api semipublic
def list_scope
model.list_options[:scope].map{ |p| [ p, attribute_get(p) ] }.to_hash
end
##
# returns the _original_ scope of the current list item
#
# @return ...?
#
# @example [Usage]
# item = Todo.get(2) # with user_id 1
# item.user_id = 2
# item.original_list_scope => { :user_id => 1 }
#
# @api semipublic
def original_list_scope
model.list_options[:scope].map{
|p| [ p, (property = properties[p]) && original_attributes.key?(property) ? original_attributes[property] : attribute_get(p) ]
}.to_hash
end
##
# returns the query conditions
#
# @return ...?
#
# @example [Usage]
# Todo.get(2).list_query => { :user_id => 1, :order => [:position] }
#
# @api semipublic
def list_query
list_scope.merge(:order => [ :position ])
end
##
# returns the list the current item belongs to
#
# @param scope Optional (Default is #list_query)
#
# @return the list items within the given scope
#
# @example [Usage]
# Todo.get(2).list => [ list of Todo items within the same scope as item]
# Todo.get(2).list(:user_id => 2 ) => [ list of Todo items with user_id => 2]
#
# @api public
def list(scope = list_query)
model.all(scope)
end
##
# repair the list this item belongs to
#
# @api public
def repair_list
model.repair_list(list_scope)
end
##
# reorder the list this item belongs to
#
# @param order ...?
#
# @return True/False based upon result
#
# @example [Usage]
# Todo.get(2).reorder_list([:title.asc])
#
# @api public
def reorder_list(order)
model.repair_list(list_scope.merge(:order => order))
end
##
# detaches a list item from the list, essentially setting the position as nil
#
# @param scope Optional (Default is #list_scope)
#
# @return the list items within the given scope
#
# @example [Usage]
#
# @api public
def detach(scope = list_scope)
list(scope).all(:position.gt => position).adjust!({ :position => -1 },true)
self.position = nil
end
##
# moves an item from one list to another
#
# @param scope must be the id value of the scope
# @param pos Optional sets the entry position for the item in the new list
#
# @example [Usage]
# Todo.get(2).move_to_list(2)
# Todo.get(2).move_to_list(2, 10)
#
# @return True/False based upon result
#
# @api public
def move_to_list(scope, pos = nil)
detach # remove from current list
attribute_set(model.list_options[:scope][0], scope.to_i) # set new scope
save # save progress. Needed to get the positions correct.
reload # get a fresh new start
move(pos) unless pos.nil?
end
##
# finds the previous _higher_ item in the list (lower in number position)
#
# @return the previous list item
#
# @example [Usage]
# Todo.get(2).left_sibling => Todo.get(1)
# Todo.get(2).higher_item => Todo.get(1)
# Todo.get(2).previous_item => Todo.get(1)
#
# @api public
def left_sibling
list.reverse.first(:position.lt => position)
end
alias_method :higher_item, :left_sibling
alias_method :previous_item, :left_sibling
##
# finds the next _lower_ item in the list (higher in number position)
#
# @return the next list item
#
# @example [Usage]
# Todo.get(2).right_sibling => Todo.get(3)
# Todo.get(2).lower_item => Todo.get(3)
# Todo.get(2).next_item => Todo.get(3)
#
# @api public
def right_sibling
list.first(:position.gt => position)
end
alias_method :lower_item, :right_sibling
alias_method :next_item, :right_sibling
##
# move item to a position in the list. position should _only_ be changed through this
#
# @example [Usage]
# * node.move :higher # moves node higher unless it is at the top of list
# * node.move :lower # moves node lower unless it is at the bottom of list
# * node.move :below => other_node # moves this node below the other resource in the list
# * node.move :above => Node.get(2) # moves this node above the other resource in the list
# * node.move :to => 2 # moves this node to the position given in the list
# * node.move(2) # moves this node to the position given in the list
#
# @param vector An integer, a symbol, or a key-value pair that describes the requested movement
#
# @option :higher move item higher
# @option :lower move item lower
# @option :up move item higher
# @option :down move item lower
# @option :highest move item to the top of the list
# @option :lowest move item to the bottom of the list
# @option :top move item to the top of the list
# @option :bottom move item to the bottom of the list
# @option :above move item above other item. must be in same scope
# @option :below move item below other item. must be in same scope
# @option :to Integer/String}> move item to a specific position in the list
# @option move item to a specific position in the list
#
# @return returns false if it cannot move to the position, otherwise true
# @see move_without_saving
#
# @api public
def move(vector)
move_without_saving(vector) && save
end
private
##
# does all the actual movement in #move, but does not save afterwards. this is used internally in
# before :create / :update. Should not be used by organic beings.
#
# @see move
#
# @api private
def move_without_saving(vector)
if vector.kind_of?(Hash)
action, object = vector.keys[0], vector.values[0]
else
action = vector
end
# set the start position to 1 or, if offset in the list_options is :list, :first => X
minpos = model.list_options[:first]
# the previous position (if changed) else current position
prepos = original_attributes[properties[:position]] || position
# set the last position in the list or previous position if the last item
maxpos = (last = list.last) ? (last == self ? prepos : last.position + 1) : minpos
newpos = case action
when :highest then minpos
when :top then minpos
when :lowest then maxpos
when :bottom then maxpos
when :higher,:up then [ position - 1, minpos ].max
when :lower,:down then [ position + 1, maxpos ].min
when :above
# the object given, can either be:
# -- the same as self
# -- already below self
# -- higher up than self (lower number in list)
( (self == object) or (object.position > self.position) ) ? self.position : object.position
when :below
# the object given, can either be:
# -- the same as self
# -- already above self
# -- lower than self (higher number in list)
( self == object or (object.position < self.position) ) ? self.position : object.position + 1
when :to
# can only move within top and bottom positions of list
# -- .move(:to => 2 ) Hash with FixNum
# -- .move(:to => '2' ) Hash with String
# NOTE:: sensitive functionality
# maxpos is incremented above, so decrement by 1 to get true maxpos
# minpos is fixed, so just take the object position value given
# else add 1 to object position value
obj = object.to_i
if (obj > maxpos)
[ minpos, [ obj, maxpos - 1 ].min ].max
else
[ minpos, [ obj, maxpos ].min ].max
end
else
raise ArgumentError, "unrecognized vector: [#{action}]. Please check your spelling and/or the docs" if action.is_a?(Symbol)
# -- .move(2) as FixNum only
# -- .move('2') as String only
if action.to_i < minpos
[ minpos, maxpos - 1 ].min
else
[ action.to_i, maxpos - 1 ].min
end
end
# don't move if already at the position
return false if [ :lower, :down, :higher, :up, :top, :bottom, :highest, :lowest, :above, :below ].include?(action) && newpos == prepos
return false if !newpos || ([ :above, :below ].include?(action) && list_scope != object.list_scope)
return true if newpos == position && position == prepos || (newpos == maxpos && position == maxpos - 1)
if !position
list.all(:position.gte => newpos).adjust!({ :position => 1 }, true) unless action =~ /:(lowest|bottom)/
elsif newpos > prepos
newpos -= 1 if [:lowest,:bottom,:above,:below].include?(action)
list.all(:position => prepos..newpos).adjust!({ :position => -1 }, true)
elsif newpos < prepos
list.all(:position => newpos..prepos).adjust!({ :position => 1 }, true)
end
self.position = newpos
self.moved = true
true
end # move_without_saving
end # InstanceMethods
end # List
end # Is
Model.append_extensions(Is::List)
end # DataMapper