lib/cequel/record/properties.rb
# -*- encoding : utf-8 -*-
module Cequel
module Record
#
# Properties on a Cequel record acts as attributes on record instances, and
# are persisted as column values to Cassandra. Properties are declared
# explicitly on a record instance in the body.
#
# Properties can be **key columns**, **data columns**, or **collection
# columns**. Key columns combine to form the primary key for the record;
# they cannot be changed once a record has been saved. Data columns contain
# scalar data values like strings, integers, and timestamps. Collection
# columns are lists, sets, or maps that can be atomically updated.
#
# All varieties of column have a type; see {Cequel::Type} for the full
# list of possibilities. A collection column's type is the type of its
# elements (in the case of a map collection, there is both a key type and a
# value type).
#
# @example
# class Post
# key :blog_subdomain, :text
# key :id, :timeuuid, auto: true
#
# column :title, :text
# column :body, :text
# column :updated_at, :timestamp
#
# list :categories, :text
# set :tags, :text
# map :referers, :text, :integer
# end
#
# @see ClassMethods Methods for defining properties
#
module Properties
extend ActiveSupport::Concern
included do
class_attribute :default_attributes, instance_writer: false
class_attribute :empty_attributes, instance_writer: false
self.default_attributes, self.empty_attributes = {}, {}
class <<self; alias_method :new_empty, :new; end
extend ConstructorMethods
attr_reader :collection_proxies
private :collection_proxies
end
# @private
module ConstructorMethods
def new(*args, &block)
new_empty.tap do |record|
record.__send__(:initialize_new_record, *args)
yield record if block_given?
end
end
end
#
# Methods for defining columns on a record
#
# @see Properties
#
module ClassMethods
protected
# rubocop:disable LineLength
# @!visibility public
#
# Define a key column. By default, the first key column defined for a
# record will be a partition key, and the following keys will be
# clustering columns. This behavior can be changed using the
# `:partition` option
#
# @param name [Symbol] the name of the key column
# @param type [Symbol] the type of the key column
# @param options [Options] options for the key column
# @option options [Boolean] :partition (false) make this a partition
# key even if it is not the first key column
# @option options [Boolean] :auto (false) automatically initialize this
# key with a UUID value for new records. Only valid for `uuid` and
# `timeuuid` columns.
# @option options [:asc,:desc] :order whether rows should be ordered
# ascending or descending by this column. Only valid for clustering
# columns
# @return [void]
#
# @note {Associations::ClassMethods#belongs_to belongs_to} implicitly
# defines key columns.
#
# @see
# http://cassandra.apache.org/doc/cql3/CQL.html#createTablepartitionClustering
# CQL documentation on compound primary keys
#
def key(name, type, options = {})
def_accessors(name)
if options.fetch(:auto, false)
unless Type[type].is_a?(Cequel::Type::Uuid)
fail ArgumentError, ":auto option only valid for UUID columns"
end
default = -> { Cequel.uuid } if options[:auto]
else
default = options[:default]
end
set_attribute_default(name, default)
end
# rubocop:enable LineLength
#
# Define a data column
#
# @param name [Symbol] the name of the column
# @param type [Symbol] the type of the column
# @param options [Options] options for the column
# @option options [Object,Proc] :default a default value for the
# column, or a proc that returns a default value for the column
# @option options [Boolean,Symbol] :index create a secondary index on
# this column
# @return [void]
#
# @note Using type :enum will behave similar to an ActiveRecord enum:
# example: `column :status, :enum, values: { open: 1, closed: 2 }`
# will be handled as type Int
# calling model.status will return the symbol ie. :open or :closed
# expects setter to be called with symbol ie. model.status(:open)
# exposes helpers ie. model.open?
# exposes values-mapping on a class-level ModelClass.status
#
# @note Secondary indexes are not nearly as flexible as primary keys:
# you cannot query for multiple values or for ranges of values. You
# also cannot combine a secondary index restriction with a primary
# key restriction in the same query, nor can you combine more than
# one secondary index restriction in the same query.
#
def column(name, type, options = {})
def_accessors(name)
def_enum(name, options[:values]) if type == :enum
set_attribute_default(name, options[:default])
end
#
# Define a list column
#
# @param name [Symbol] the name of the list
# @param type [Symbol] the type of the elements in the list
# @param options [Options] options for the list
# @option options [Object,Proc] :default ([]) a default value for the
# column, or a proc that returns a default value for the column
# @return [void]
#
# @see Record::List
# @since 1.0.0
#
def list(name, type, options = {})
def_collection_accessors(name, List)
set_attribute_default(name, options[:default])
set_empty_attribute(name) { [] }
end
#
# Define a set column
#
# @param name [Symbol] the name of the set
# @param type [Symbol] the type of the elements in the set
# @param options [Options] options for the set
# @option options [Object,Proc] :default (Set[]) a default value for
# the column, or a proc that returns a default value for the column
# @return [void]
#
# @see Record::Set
# @since 1.0.0
#
def set(name, type, options = {})
def_collection_accessors(name, Set)
set_attribute_default(name, options[:default])
set_empty_attribute(name) { ::Set[] }
end
#
# Define a map column
#
# @param name [Symbol] the name of the map
# @param key_type [Symbol] the type of the keys in the set
# @param options [Options] options for the set
# @option options [Object,Proc] :default ({}) a default value for the
# column, or a proc that returns a default value for the column
# @return [void]
#
# @see Record::Map
# @since 1.0.0
#
def map(name, key_type, value_type, options = {})
def_collection_accessors(name, Map)
set_attribute_default(name, options[:default])
set_empty_attribute(name) { {} }
end
private
def def_enum(name, values)
name = name.to_sym
def_enum_values(name, values)
def_enum_reader(name, values)
def_enum_writer(name, values)
end
def def_enum_values(name, values)
define_singleton_method(name) { values }
end
def def_enum_reader(name, values)
module_eval <<-RUBY, __FILE__, __LINE__+1
def #{name}; #{values.invert}[read_attribute(#{name.inspect})]; end
RUBY
values.each do |key, value|
module_eval <<-RUBY, __FILE__, __LINE__+1
def #{key}?; read_attribute(#{name.inspect}) == #{value}; end
RUBY
end
end
def def_enum_writer(name, values)
module_eval <<-RUBY, __FILE__, __LINE__+1
def #{name}=(value); write_attribute(#{name.inspect}, #{values}[value]); end
RUBY
end
def def_accessors(name)
name = name.to_sym
def_reader(name)
def_writer(name)
end
def def_reader(name)
module_eval <<-RUBY, __FILE__, __LINE__+1
def #{name}; read_attribute(#{name.inspect}); end
RUBY
end
def def_writer(name)
module_eval <<-RUBY, __FILE__, __LINE__+1
def #{name}=(value); write_attribute(#{name.inspect}, value); end
RUBY
end
def def_collection_accessors(name, collection_proxy_class)
def_collection_reader(name, collection_proxy_class)
def_collection_writer(name)
end
def def_collection_reader(name, collection_proxy_class)
module_eval <<-RUBY, __FILE__, __LINE__+1
def #{name}
proxy_collection(#{name.inspect}, #{collection_proxy_class})
end
RUBY
end
def def_collection_writer(name)
module_eval <<-RUBY, __FILE__, __LINE__+1
def #{name}=(value)
reset_collection_proxy(#{name.inspect})
write_attribute(#{name.inspect}, value)
end
RUBY
end
def set_attribute_default(name, default)
default_attributes[name.to_sym] = default
end
def set_empty_attribute(name, &block)
empty_attributes[name.to_sym] = block
end
end
# @private
def initialize(attributes = {}, record_collection = nil)
@cequel_attributes, @record_collection = attributes, record_collection
@collection_proxies = {}
end
#
# @return [Array<Symbol>] list of names of attributes on this record
#
def attribute_names
@cequel_attributes.keys
end
#
# @return [Hash<String,Object>] map of column names to values currently
# set on this record
#
def attributes
attribute_names
.each_with_object(HashWithIndifferentAccess.new) do |name, attributes|
attributes[name] = read_attribute(name)
end
end
#
# Set attributes on the record. Each attribute is set via the setter
# method; virtual (non-column) attributes are allowed.
#
# @param attributes [Hash] map of attribute names to values
# @return [void]
#
def attributes=(attributes)
attributes.each_pair do |attribute, value|
__send__(:"#{attribute}=", value)
end
end
#
# Read an attribute
#
# @param column_name [Symbol] the name of the column
# @return the value of that column
# @raise [MissingAttributeError] if the attribute has not been loaded
# @raise [UnknownAttributeError] if the attribute does not exist
#
def [](column_name)
read_attribute(column_name)
end
#
# Write an attribute
#
# @param column_name [Symbol] name of the column to write
# @param value the value to write to the column
# @return [void]
# @raise [UnknownAttributeError] if the attribute does not exist
#
def []=(column_name, value)
write_attribute(column_name, value)
end
#
# @return [Boolean] true if this record has the same type and key
# attributes as the other record
def ==(other)
if key_values.any? { |value| value.nil? }
super
else
self.class == other.class && key_values == other.key_values
end
end
#
# @return [String] string representation of the record
#
def inspect
inspected_attributes = attributes.each_pair.map do |attr, value|
inspected_value = Cequel.uuid?(value) ?
value.to_s :
value.inspect
"#{attr}: #{inspected_value}"
end
"#<#{self.class} #{inspected_attributes.join(", ")}>"
end
protected
def read_attribute(name)
@cequel_attributes.fetch(name)
rescue KeyError
if self.class.reflect_on_column(name)
fail MissingAttributeError, "missing attribute: #{name}"
else
fail UnknownAttributeError, "unknown attribute: #{name}"
end
end
def write_attribute(name, value)
unless self.class.reflect_on_column(name)
fail UnknownAttributeError, "unknown attribute: #{name}"
end
send("#{name}_will_change!") unless value === read_attribute(name)
@cequel_attributes[name] = value
end
private
def proxy_collection(column_name, proxy_class)
column = self.class.reflect_on_column(column_name)
collection_proxies[column_name] ||= proxy_class.new(self, column)
end
def reset_collection_proxy(name)
collection_proxies.delete(name)
end
def init_attributes(new_attributes)
@cequel_attributes = {}
new_attributes.each_pair do |name, value|
if value.nil?
value = empty_attributes.fetch(name.to_sym) { -> {} }.call
end
@cequel_attributes[name.to_sym] = value
end
@cequel_attributes
end
def initialize_new_record(attributes = {})
dynamic_defaults = default_attributes
.select { |name, value| value.is_a?(Proc) }
new_attributes =
Util.deep_copy(default_attributes.except(*dynamic_defaults.keys))
dynamic_defaults.each { |name, p| new_attributes[name] = p.call }
init_attributes(new_attributes)
@new_record = true
yield self if block_given?
self.attributes = attributes
loaded!
self
end
end
end
end