lib/cequel/record/collection.rb
# -*- encoding : utf-8 -*-
require 'delegate'
module Cequel
module Record
#
# The value of a collection column in a {Record}. Collections track
# modifications that can be expressed as atomic collection mutations in
# CQL, and persist those modifications when their owning record is saved.
# Such modifications can be done even if the collection has not loaded
# data from CQL, in the case of an unloaded record or where the collection
# column was not included in the `SELECT` statement.
#
# Mutation operations that require reading data before writing it are not
# supported (e.g. `Array#map!).
#
# Each collection implementation wraps a built-in Ruby collection type.
#
# @abstract Including classes must descend from `Delegator` and implement
# the `::empty` class method.
#
# @example
# class Blog
# include Cequel::Record
#
# key :subdomain
#
# list :categories, :text
# end
#
# # Get an unloaded Blog instance; no data read
# blog = Blog['cassandra']
#
# # Stage modification to collection, still no data read
# blog.categories << 'Big Data'
#
# # Issue an UPDATE statement which pushes "Big Data" onto the
# # collection. Still no data read
# blog.save!
#
# # Stage another modification to the collection
# blog.categories.unshift('Distributed Database')
#
# # Collection is lazily read from the database, and then staged
# # modifications are made to the loaded collection
# puts blog.categories.join(', ')
#
# # Issues an UPDATE statement which prepends "Distributed Data" onto the
# # collection
# blog.save!
#
# @since 1.0.0
#
module Collection
extend ActiveSupport::Concern
extend Util::Forwardable
#
# @!method loaded?
# @return [Boolean] `true` if the collection's contents are loaded into
# memory
#
def_delegators :@model, :loaded?, :updater, :deleter
private :updater, :deleter
#
# @!method column_name
# @return [Symbol] the name of the collection column
#
def_delegator :@column, :name, :column_name
def_delegators :__getobj__, :clone, :dup
included do
define_method(
:method_missing,
BasicObject.instance_method(:method_missing))
private :method_missing
end
#
# @param model [Record] record that contains this collection
# @param column [Schema::Column] column this collection's data belongs to
# @return [Collection] a new collection
#
def initialize(model, column)
@model, @column = model, column
end
#
# @return [String] inspected underlying Ruby collection object
#
def inspect
__getobj__.inspect
end
#
# Notify the collection that its underlying data is loaded in memory.
#
# @return [void]
#
# @api private
#
def loaded!
modifications.each { |modification| modification.call() }.clear
end
#
# Notify the collection that its staged changes have been written to the
# data store.
#
# @return [void]
#
# @api private
#
def persisted!
modifications.clear
end
protected
def __getobj__
model.__send__(:read_attribute, column_name)
end
def __setobj__(obj)
fail "Attempted to call __setobj__ on read-only delegate!"
end
private
attr_reader :model, :column
def_delegator :column, :cast, :cast_collection
def_delegator 'column.type', :cast, :cast_element
private :cast_collection, :cast_element
def to_modify(&block)
if loaded?
model.__send__("#{column_name}_will_change!")
block.call
else modifications << block
end
self
end
def to_update
yield unless model.new_record?
end
def modifications
@modifications ||= []
end
end
#
# The value of a list column in a {Record} instance. List collections
# encapsulate and behave like the built-in `Array` type.
#
# @see http://cassandra.apache.org/doc/cql3/CQL.html#list
# CQL documentation for the list type
# @since 1.0.0
#
class List < DelegateClass(Array)
include Collection
# These methods are not available on lists because they require reading
# collection data before writing it.
NON_ATOMIC_MUTATORS = [
:collect!,
:delete_if,
:fill,
:flatten!,
:insert,
:keep_if,
:map!,
:pop,
:reject!,
:reverse!,
:rotate!,
:select!,
:shift,
:shuffle!,
:slice!,
:sort!,
:sort_by!,
:uniq!
]
NON_ATOMIC_MUTATORS
.each { |method| undef_method(method) if method_defined? method }
#
# Set the value at a position or range of positions. This modification
# will be staged and persisted as an atomic list update when the record
# is saved. If the collection data is loaded in memory, it will also be
# modified accordingly.
#
# @return [void]
#
# @see DataSet#list_replace
# @note Negative positions are not supported, as they are not allowed in
# CQL list operations.
#
# @overload []=(position, element)
#
# @param position [Integer] position at which to set element
# @param element element to insert at position in list
#
# @overload []=(range, elements)
#
# @param range [Range] range of positions at which to replace elements
# @param elements [Array] new elements to replace in this range
#
# @overload []=(start_position, count, elements)
#
# @param start_position [Integer] position at which to begin replacing
# elements
# @param count [Integer] number of elements to replace
# @param elements [Array] new elements to replace in this range
#
def []=(*args)
if args[0].is_a?(Integer) && args.count == 2
# single element set/replace
elem = cast_element(args[1])
to_update do
set_item(args[0], elem)
end
to_modify { super(args[0], elem) }
else
# multi-element set/replace
range = if args[0].is_a?(Range)
args[0]
elsif args[0].is_a?(Integer) && args[1].is_a?(Integer)
args[0]..(args[0]+args[1]-1)
else
Kernel.raise ArgumentError, "[i]=elem or [i,count]=elems or [a..b]=elems"
end
elems = cast_collection(Array.wrap(args[-1]))
to_update do
set_range(range, elems, args[-1])
end
to_modify { super(range, elems) }
end
end
#
# Remove all elements from the list. This will propagate to the database
# as a DELETE of the list column.
#
# @return [List] self
#
def clear
to_update { deleter.delete_columns(column_name) }
to_modify { super }
end
#
# Concatenate another collection onto this list.
#
# @param array [Array] elements to concatenate
# @return [List] self
#
def concat(array)
array = cast_collection(array)
to_update { updater.list_append(column_name, array) }
to_modify { super }
end
#
# Remove all instances of a given value from the list.
#
# @param object value to remove
# @return [List] self
#
def delete(object)
object = cast_element(object)
to_update { updater.list_remove(column_name, object) }
to_modify { super }
end
#
# Remove the element at a given position from the list.
#
# @param index [Integer] position from which to remove the element
# @return [List] self
#
def delete_at(index)
to_update { deleter.list_remove_at(column_name, index) }
to_modify { super }
end
#
# Push (append) one or more elements to the end of the list.
#
# @param objects value(s) to add to the end of the list
# @return [List] self
#
def push(*objects)
objects.map! { |object| cast_element(object) }
to_update { updater.list_append(column_name, objects) }
to_modify { super }
end
alias_method :<<, :push
alias_method :append, :push
#
# Replace the entire contents of this list with a new collection
#
# @param array [Array] new elements for this list
# @return [List] self
#
def replace(array)
array = cast_collection(array)
to_update { updater.set(column_name => array) }
to_modify { super }
end
#
# Prepend one or more values to the beginning of this list
#
# @param objects value(s) to add to the beginning of the list
# @return [List] self
#
def unshift(*objects)
objects.map!(&method(:cast_element))
prepared = @model.class.connection.bug8733_version? ? objects.reverse : objects
to_update { updater.list_prepend(column_name, prepared) }
to_modify { super }
end
alias_method :prepend, :unshift
protected
def set_item(position, elem)
elem = cast_element(elem)
updater.list_replace(column_name, position, elem)
end
def set_range(range, elems, _raw_elems)
replace_range = if elems.count < range.count
range.begin..(range.begin+elems.count-1)
else
range
end
delete_range = (replace_range.end+1)..range.end
replace_range.zip(elems).each do |position, elem|
updater.list_replace(column_name, position, elem)
end
delete_range.to_a.reverse.each do |position|
deleter.list_remove_at(column_name, position)
end
end
end
#
# The value of a set column in a {Record} instance. Contains an unordered,
# unique set of elements. Encapsulates and behaves like the `Set` type from
# the standard library.
#
# @see http://cassandra.apache.org/doc/cql3/CQL.html#set
# CQL documentation for set columns
# @since 1.0.0
#
class Set < DelegateClass(::Set)
include Collection
# These methods are not implemented because they cannot be expressed as a
# single CQL3 write operation.
NON_ATOMIC_MUTATORS = [
:add?,
:collect!,
:delete?,
:delete_if,
:flatten!,
:keep_if,
:map!,
:reject!,
:select!
]
NON_ATOMIC_MUTATORS
.each { |method| undef_method(method) if method_defined? method }
#
# Add an element to the set
#
# @param object element to add
# @return [Set] self
#
def add(object)
object = cast_element(object)
to_update { updater.set_add(column_name, object) }
to_modify { super }
end
alias_method :<<, :add
#
# Remove everything from the set. Equivalent to deleting the collection
# column from the record's row.
#
# @return [Set] self
#
def clear
to_update { deleter.delete_columns(column_name) }
to_modify { super }
end
#
# Remove a single element from the set
#
# @param object element to remove
# @return [Set] self
#
def delete(object)
object = cast_element(object)
to_update { updater.set_remove(column_name, object) }
to_modify { super }
end
#
# Replace the entire contents of this set with another set
#
# @param set [::Set] set containing new elements
# @return [Set] self
#
def replace(set)
set = cast_collection(set)
to_update { updater.set(column_name => set) }
to_modify { super }
end
end
#
# The value of a `map` column in a {Record} instance. Encapsulates and
# behaves like a built-in `Hash`.
#
# @see http://cassandra.apache.org/doc/cql3/CQL.html#map
# CQL documentation for map columns
# @since 1.0.0
#
class Map < DelegateClass(::Hash)
include Collection
extend Util::Forwardable
# These methods involve mutation that cannot be expressed as a CQL
# operation, so are not implemented.
NON_ATOMIC_MUTATORS = [
:default,
:default=,
:default_proc,
:default_proc=,
:delete_if,
:deep_merge!,
:except!,
:extract!,
:keep_if,
:reject!,
:reverse_merge!,
:reverse_update,
:select!,
:shift,
:slice!,
:stringify_keys!,
:symbolize_keys!,
:to_options!,
:transform_keys!
]
NON_ATOMIC_MUTATORS
.each { |method| undef_method(method) if method_defined? method }
#
# Set the value of a given key
#
# @param key the key
# @param value the value
# @return [Map] self
#
def []=(key, value)
key = cast_key(key)
to_update { updater.map_update(column_name, key => value) }
to_modify { super }
end
alias_method :store, :[]=
#
# Remove all elements from this map. Equivalent to deleting the column
# value from the row in CQL
#
# @return [Map] self
#
def clear
to_update { deleter.delete_columns(column_name) }
to_modify { super }
end
#
# Delete one key from the map
#
# @param key the key to delete
# @return [Map] self
#
def delete(key)
key = cast_key(key)
to_update { deleter.map_remove(column_name, key) }
to_modify { super }
end
#
# Update a collection of keys and values given by a hash
#
# @param hash [Hash] hash containing keys and values to set
# @return [Map] self
#
def merge!(hash)
hash = cast_collection(hash)
to_update { updater.map_update(column_name, hash) }
to_modify { super }
end
alias_method :update, :merge!
#
# Replace the entire contents of this map with a new one
#
# @param hash [Hash] hash containing new keys and values
# @return [Map] self
#
def replace(hash)
hash = cast_collection(hash)
to_update { updater.set(column_name => hash) }
to_modify { super }
end
private
def_delegator 'column.key_type', :cast, :cast_key
private :cast_key
end
end
end