├── .rspec ├── script ├── console └── spec ├── .yardopts ├── spec ├── examples │ ├── record │ │ ├── spec_helper.rb │ │ ├── naming_spec.rb │ │ ├── scoped_spec.rb │ │ ├── serialization_spec.rb │ │ ├── mass_assignment_spec.rb │ │ ├── dirty_spec.rb │ │ ├── validations_spec.rb │ │ ├── callbacks_spec.rb │ │ ├── schema_spec.rb │ │ ├── set_spec.rb │ │ ├── finders_spec.rb │ │ └── properties_spec.rb │ ├── uuids_spec.rb │ ├── spec_helper.rb │ ├── metal │ │ └── keyspace_spec.rb │ └── schema │ │ ├── table_updater_spec.rb │ │ ├── table_synchronizer_spec.rb │ │ └── table_writer_spec.rb ├── environment.rb ├── support │ ├── en.yml │ ├── result_stub.rb │ └── helpers.rb └── shared │ └── readable_dictionary.rb ├── .gitignore ├── lib ├── cequel │ ├── version.rb │ ├── errors.rb │ ├── record │ │ ├── conversion.rb │ │ ├── configuration_generator.rb │ │ ├── record_generator.rb │ │ ├── mass_assignment.rb │ │ ├── errors.rb │ │ ├── callbacks.rb │ │ ├── railtie.rb │ │ ├── bulk_writes.rb │ │ ├── dirty.rb │ │ ├── scoped.rb │ │ ├── belongs_to_association.rb │ │ ├── tasks.rb │ │ ├── has_many_association.rb │ │ ├── validations.rb │ │ ├── data_set_builder.rb │ │ ├── lazy_record_collection.rb │ │ ├── association_collection.rb │ │ ├── finders.rb │ │ ├── bound.rb │ │ └── schema.rb │ ├── schema.rb │ ├── metal │ │ ├── logging.rb │ │ ├── new_relic_instrumentation.rb │ │ ├── cql_row_specification.rb │ │ ├── incrementer.rb │ │ ├── row_specification.rb │ │ ├── statement.rb │ │ ├── inserter.rb │ │ ├── request_logger.rb │ │ ├── row.rb │ │ ├── batch_manager.rb │ │ ├── deleter.rb │ │ ├── writer.rb │ │ ├── batch.rb │ │ └── updater.rb │ ├── metal.rb │ ├── util.rb │ ├── uuids.rb │ ├── schema │ │ ├── create_table_dsl.rb │ │ ├── update_table_dsl.rb │ │ ├── table_writer.rb │ │ ├── table_property.rb │ │ ├── migration_validator.rb │ │ ├── table_updater.rb │ │ ├── keyspace.rb │ │ ├── table_synchronizer.rb │ │ └── table_reader.rb │ └── record.rb └── cequel.rb ├── gemfiles ├── thrift.gemfile ├── rails_4.0.gemfile ├── rails_4.1.gemfile ├── rails_3.1.gemfile └── rails_3.2.gemfile ├── templates ├── record.rb └── config │ └── cequel.yml ├── .rubocop.yml ├── Gemfile ├── Appraisals ├── .travis.yml ├── LICENSE ├── rubocop-todo.yml ├── cequel.gemspec ├── CONTRIBUTING.md ├── Rakefile └── Vagrantfile /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | irb -r./spec/environment 4 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | -m markdown 2 | --no-private 3 | --safe 4 | --hide-void-return 5 | --files CHANGELOG.md,LICENSE 6 | --embed-mixins 7 | -------------------------------------------------------------------------------- /spec/examples/record/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require File.expand_path('../../spec_helper', __FILE__) 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .yardoc 2 | doc 3 | *.gem 4 | tags 5 | .vagrant 6 | Gemfile.lock 7 | gemfiles/*.gemfile.lock 8 | .bundle 9 | .ruby-version 10 | -------------------------------------------------------------------------------- /lib/cequel/version.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | # The current version of the library 4 | VERSION = '1.2.5' 5 | end 6 | -------------------------------------------------------------------------------- /spec/environment.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require 'bundler' 3 | 4 | if ENV['CI'] 5 | Bundler.require(:default, :development) 6 | else 7 | Bundler.require(:default, :development, :debug) 8 | end 9 | -------------------------------------------------------------------------------- /gemfiles/thrift.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "racc", "~> 1.4", :platforms=>:rbx 6 | gem "rubysl", "~> 2.0", :platforms=>:rbx 7 | gem "psych", "~> 2.0", :platforms=>:rbx 8 | gem "cassandra-cql", "~> 1.2" 9 | 10 | gemspec :path=>"../" -------------------------------------------------------------------------------- /spec/support/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | cequel: 3 | models: 4 | post: Blog post 5 | attributes: 6 | post: 7 | title: Post title 8 | errors: 9 | models: 10 | post: 11 | attributes: 12 | title: 13 | blank: is a required field 14 | -------------------------------------------------------------------------------- /gemfiles/rails_4.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "racc", "~> 1.4", :platforms=>:rbx 6 | gem "rubysl", "~> 2.0", :platforms=>:rbx 7 | gem "psych", "~> 2.0", :platforms=>:rbx 8 | gem "activemodel", "~> 4.0.0" 9 | 10 | gemspec :path=>"../" -------------------------------------------------------------------------------- /gemfiles/rails_4.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "racc", "~> 1.4", :platforms=>:rbx 6 | gem "rubysl", "~> 2.0", :platforms=>:rbx 7 | gem "psych", "~> 2.0", :platforms=>:rbx 8 | gem "activemodel", "~> 4.1.0" 9 | 10 | gemspec :path=>"../" -------------------------------------------------------------------------------- /gemfiles/rails_3.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "racc", "~> 1.4", :platforms=>:rbx 6 | gem "rubysl", "~> 2.0", :platforms=>:rbx 7 | gem "psych", "~> 2.0", :platforms=>:rbx 8 | gem "activemodel", "~> 3.1.0" 9 | gem "tzinfo", "~> 0.3" 10 | 11 | gemspec :path=>"../" -------------------------------------------------------------------------------- /gemfiles/rails_3.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "racc", "~> 1.4", :platforms=>:rbx 6 | gem "rubysl", "~> 2.0", :platforms=>:rbx 7 | gem "psych", "~> 2.0", :platforms=>:rbx 8 | gem "activemodel", "~> 3.2.0" 9 | gem "tzinfo", "~> 0.3" 10 | 11 | gemspec :path=>"../" -------------------------------------------------------------------------------- /lib/cequel/errors.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | # 4 | # @since 1.0.0 5 | # 6 | # Raised when the schema defined in Cassandra cannot be modified to match 7 | # the schema defined in the application (e.g., changing the type of a primary 8 | # key) 9 | # 10 | InvalidSchemaMigration = Class.new(StandardError) 11 | end 12 | -------------------------------------------------------------------------------- /templates/record.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | class <%= class_name %> 3 | include Cequel::Record 4 | 5 | key :id, :uuid, auto: true 6 | <%- attributes.each do |attribute| -%> 7 | column <%= attribute.name.to_sym.inspect %>, <%= attribute.type.to_sym.inspect %><% if attribute.has_index? %>, index: true<% end %> 8 | <%- end -%> 9 | end 10 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: rubocop-todo.yml 2 | 3 | Documentation: 4 | Exclude: 5 | - "lib/cequel/version.rb" 6 | - "lib/cequel/errors.rb" 7 | - "lib/cequel/record/errors.rb" 8 | 9 | HashSyntax: 10 | Exclude: 11 | - "lib/cequel/record/tasks.rb" 12 | 13 | IfUnlessModifier: 14 | Enabled: false 15 | 16 | DoubleNegation: 17 | Enabled: false 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :debug do 6 | gem 'debugger', '~> 1.6', :platforms => :mri_19 7 | gem 'byebug', '~> 2.7', :platforms => [:mri_20, :mri_21] 8 | gem 'pry', '~> 0.9' 9 | end 10 | 11 | gem 'racc', '~> 1.4', :platforms => :rbx 12 | gem 'rubysl', '~> 2.0', :platforms => :rbx 13 | gem 'psych', '~> 2.0', :platforms => :rbx 14 | -------------------------------------------------------------------------------- /lib/cequel/record/conversion.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Provides support for ActiveModel::Conversions 6 | # 7 | module Conversion 8 | extend ActiveSupport::Concern 9 | 10 | included do 11 | include ActiveModel::Conversion 12 | alias_method :to_key, :key_values 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-4.1" do 2 | gem "activemodel", "~> 4.1.0" 3 | end 4 | 5 | appraise "rails-4.0" do 6 | gem "activemodel", "~> 4.0.0" 7 | end 8 | 9 | appraise "rails-3.2" do 10 | gem "activemodel", "~> 3.2.0" 11 | gem "tzinfo", "~> 0.3" 12 | end 13 | 14 | appraise "rails-3.1" do 15 | gem "activemodel", "~> 3.1.0" 16 | gem "tzinfo", "~> 0.3" 17 | end 18 | 19 | appraise "thrift" do 20 | gem "cassandra-cql", "~> 1.2" 21 | end 22 | -------------------------------------------------------------------------------- /spec/examples/record/naming_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require_relative 'spec_helper' 3 | 4 | describe 'naming' do 5 | model :Blog do 6 | key :subdomain, :text 7 | column :name, :text 8 | end 9 | 10 | it 'should implement model_name' do 11 | Blog.model_name.should == 'Blog' 12 | end 13 | 14 | it 'should implement model_name interpolations' do 15 | Blog.model_name.i18n_key.should == :blog 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /script/spec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | flags, args = ARGV.partition { |arg| arg[0] == '-' } 4 | pattern, line_number = *args 5 | 6 | files = pattern ? Dir.glob("spec/examples/**/*#{pattern}*") : 'spec/examples' 7 | abort "No spec files match `#{pattern}'" if pattern && files.empty? 8 | line_args = ['-l', line_number] if line_number 9 | 10 | command = ['bundle', 'exec', 'rspec', '--fail-fast', *flags, *line_args, *files] 11 | puts command.join(' ') 12 | system(*command) 13 | -------------------------------------------------------------------------------- /spec/support/result_stub.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | 4 | module SpecSupport 5 | 6 | RowStub = Struct.new(:to_hash) 7 | 8 | class ResultStub 9 | 10 | def initialize(rows) 11 | @rows = rows 12 | end 13 | 14 | def fetch 15 | while row = fetch_row 16 | yield RowStub.new(row) 17 | end 18 | end 19 | 20 | def fetch_row 21 | @rows.shift 22 | end 23 | 24 | end 25 | 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /lib/cequel/record/configuration_generator.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Rails generator for a default configuration file 6 | # 7 | # @since 1.0.0 8 | # 9 | class ConfigurationGenerator < Rails::Generators::Base 10 | namespace 'cequel:configuration' 11 | source_root File.expand_path('../../../../templates/', __FILE__) 12 | 13 | def create_configuration 14 | template "config/cequel.yml" 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /templates/config/cequel.yml: -------------------------------------------------------------------------------- 1 | <% app_name = Cequel::Record::Railtie.app_name -%> 2 | development: 3 | host: '127.0.0.1' 4 | port: 9042 5 | keyspace: <%= app_name %>_development 6 | 7 | test: 8 | host: '127.0.0.1' 9 | port: 9042 10 | keyspace: <%= app_name %>_test 11 | 12 | production: 13 | hosts: 14 | - 'cass1.<%= app_name %>.biz' 15 | - 'cass2.<%= app_name %>.biz' 16 | - 'cass3.<%= app_name %>.biz' 17 | port: 9042 18 | keyspace: <%= app_name %>_production 19 | username: 'myappuser' 20 | password: 'password1' 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - "2.1" 4 | - "2.0" 5 | - "1.9.3" 6 | - "rbx-2.2" 7 | - "jruby-1.7" 8 | gemfile: 9 | - "gemfiles/rails_3.1.gemfile" 10 | - "gemfiles/rails_3.2.gemfile" 11 | - "gemfiles/rails_4.0.gemfile" 12 | - "gemfiles/rails_4.1.gemfile" 13 | script: 'bundle exec rake test' 14 | before_install: 15 | - sudo sh -c "echo 'JVM_OPTS=\"\${JVM_OPTS} -Djava.net.preferIPv4Stack=false\"' >> /usr/local/cassandra/conf/cassandra-env.sh" 16 | - sudo service cassandra start 17 | bundler_args: '--without=debug' 18 | services: 19 | - cassandra 20 | -------------------------------------------------------------------------------- /spec/examples/record/scoped_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require_relative 'spec_helper' 3 | 4 | describe Cequel::Record::Scoped do 5 | model :Post do 6 | key :blog_subdomain, :text 7 | key :id, :uuid, auto: true 8 | column :name, :text 9 | end 10 | 11 | it 'should use current scoped key values to populate new record' do 12 | Post['bigdata'].new.blog_subdomain.should == 'bigdata' 13 | end 14 | 15 | it "should not mess up class' #puts" do 16 | StringIO.new.tap do |out| 17 | out.puts Post 18 | out.string.should == "Post\n" 19 | end 20 | 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/examples/record/serialization_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require_relative 'spec_helper' 3 | 4 | describe 'serialization' do 5 | model :Post do 6 | key :blog_subdomain, :text 7 | key :id, :uuid, auto: true 8 | column :title, :text 9 | column :body, :text 10 | end 11 | 12 | uuid :id 13 | 14 | let(:attributes) do 15 | { 16 | blog_subdomain: 'big-data', 17 | id: id, 18 | title: 'Cequel', 19 | } 20 | end 21 | 22 | it 'should provide JSON serialization' do 23 | Post.include_root_in_json = false 24 | Post.new(attributes).as_json.symbolize_keys. 25 | should == attributes.merge(body: nil) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/cequel/record/record_generator.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Rails generator to create a record class 6 | # 7 | # @since 1.0.0 8 | # 9 | class RecordGenerator < Rails::Generators::NamedBase 10 | namespace 'cequel' 11 | source_root File.expand_path('../../../../templates', __FILE__) 12 | argument :attributes, type: :array, default: [], 13 | banner: 'field:type[:index] field:type[:index]' 14 | 15 | # 16 | # Create a Record implementation 17 | # 18 | def create_record 19 | template 'record.rb', 20 | File.join('app/models', class_path, "#{file_name}.rb") 21 | end 22 | end 23 | end 24 | end 25 | 26 | -------------------------------------------------------------------------------- /lib/cequel/schema.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require 'cequel/schema/column' 3 | require 'cequel/schema/create_table_dsl' 4 | require 'cequel/schema/keyspace' 5 | require 'cequel/schema/migration_validator' 6 | require 'cequel/schema/table' 7 | require 'cequel/schema/table_property' 8 | require 'cequel/schema/table_reader' 9 | require 'cequel/schema/table_synchronizer' 10 | require 'cequel/schema/table_updater' 11 | require 'cequel/schema/table_writer' 12 | require 'cequel/schema/update_table_dsl' 13 | 14 | module Cequel 15 | # 16 | # The Schema module provides full read/write access to keyspace and table 17 | # schemas defined in Cassandra. 18 | # 19 | # @see Schema::Keyspace 20 | # 21 | # @since 1.0.0 22 | # 23 | module Schema 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/cequel/metal/logging.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # Methods to handle logging for {Keyspace} instances 6 | # 7 | module Logging 8 | extend Forwardable 9 | def_delegators :request_logger, :logger, :logger=, :slowlog_threshold, 10 | :slowlog_threshold= 11 | 12 | # 13 | # @deprecated 14 | # 15 | def slowlog=(slowlog) 16 | warn "#slowlog= is deprecated and ignored" 17 | end 18 | 19 | protected 20 | 21 | attr_writer :request_logger 22 | 23 | def request_logger 24 | @request_logger ||= RequestLogger.new 25 | end 26 | 27 | def_delegator :request_logger, :log 28 | protected :log 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/cequel/metal/new_relic_instrumentation.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | begin 3 | require 'new_relic/agent/method_tracer' 4 | rescue LoadError => e 5 | fail LoadError, "Can't use NewRelic instrumentation without NewRelic gem" 6 | end 7 | 8 | module Cequel 9 | module Metal 10 | # 11 | # Provides NewRelic instrumentation for CQL queries. 12 | # 13 | module NewRelicInstrumentation 14 | extend ActiveSupport::Concern 15 | 16 | included do 17 | include NewRelic::Agent::MethodTracer 18 | 19 | add_method_tracer :execute, 20 | 'Database/Cassandra/#{args[0][/^[A-Z ]*[A-Z]/]' \ 21 | '.sub(/ FROM$/, \'\')}' 22 | end 23 | end 24 | end 25 | end 26 | 27 | Cequel::Metal::Keyspace.module_eval do 28 | include Cequel::Metal::NewRelicInstrumentation 29 | end 30 | -------------------------------------------------------------------------------- /spec/examples/uuids_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require_relative 'spec_helper' 3 | 4 | describe Cequel::Uuids do 5 | describe '#uuid' do 6 | specify { Cequel.uuid.is_a?(Cql::TimeUuid) } 7 | specify { Cequel.uuid != Cequel.uuid } 8 | specify { time = Time.now; Cequel.uuid(time).to_time == time } 9 | specify { time = DateTime.now; Cequel.uuid(time).to_time == time.to_time } 10 | specify { time = Time.zone.now; Cequel.uuid(time).to_time == time.to_time } 11 | specify { val = Cequel.uuid.value; Cequel.uuid(val).value == val } 12 | specify { str = Cequel.uuid.to_s; Cequel.uuid(str).to_s == str } 13 | end 14 | 15 | describe '#uuid?' do 16 | specify { Cequel.uuid?(Cequel.uuid) } 17 | specify { !Cequel.uuid?(Cequel.uuid.to_s) } 18 | if defined? SimpleUUID::UUID 19 | specify { !Cequel.uuid?(SimpleUUID::UUID.new) } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/cequel/metal.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require 'cequel/metal/batch' 3 | require 'cequel/metal/batch_manager' 4 | require 'cequel/metal/cql_row_specification' 5 | require 'cequel/metal/data_set' 6 | require 'cequel/metal/logging' 7 | require 'cequel/metal/keyspace' 8 | require 'cequel/metal/request_logger' 9 | require 'cequel/metal/row' 10 | require 'cequel/metal/row_specification' 11 | require 'cequel/metal/statement' 12 | require 'cequel/metal/writer' 13 | require 'cequel/metal/deleter' 14 | require 'cequel/metal/incrementer' 15 | require 'cequel/metal/inserter' 16 | require 'cequel/metal/updater' 17 | 18 | module Cequel 19 | # 20 | # The Cequel::Metal layer provides a low-level interface to the Cassandra 21 | # database. Most of the functionality is exposed via the DataSet class, which 22 | # encapsulates a table with optional filtering, and provides an interface for 23 | # constructing read and write queries. The Metal layer is not schema-aware, 24 | # and relies on the user to construct valid CQL queries. 25 | # 26 | # @see Keyspace 27 | # @see DataSet 28 | # @since 1.0.0 29 | # 30 | module Metal 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/cequel/metal/cql_row_specification.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # Encapsulates a row specification (`WHERE` clause) specified by a CQL 6 | # string 7 | # 8 | # @api private 9 | # 10 | class CqlRowSpecification 11 | # 12 | # Build a new row specification 13 | # 14 | # @param (see #initialize) 15 | # @return [Array] 16 | # 17 | def self.build(condition, bind_vars) 18 | [new(condition, bind_vars)] 19 | end 20 | 21 | # 22 | # Create a new row specification 23 | # 24 | # @param [String] condition CQL string representing condition 25 | # @param [Array] bind_vars Bind variables 26 | # 27 | def initialize(condition, bind_vars) 28 | @condition, @bind_vars = condition, bind_vars 29 | end 30 | 31 | # 32 | # CQL and bind variables for this condition 33 | # 34 | # @return [Array] CQL string followed by zero or more bind variables 35 | def cql 36 | [@condition, *@bind_vars] 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2012 Brewster Inc., Mat Brown 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /lib/cequel.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require 'delegate' 3 | 4 | require 'active_support' 5 | require 'active_support/deprecation' 6 | require 'active_support/core_ext' 7 | require 'cql' 8 | 9 | require 'cequel/errors' 10 | require 'cequel/metal' 11 | require 'cequel/schema' 12 | require 'cequel/type' 13 | require 'cequel/util' 14 | require 'cequel/uuids' 15 | require 'cequel/record' 16 | 17 | # 18 | # Cequel is a library providing robust data modeling and query building 19 | # capabilities for Cassandra using CQL3. 20 | # 21 | # @see Cequel::Record Cequel::Record, an object-row mapper for CQL3 22 | # @see Cequel::Metal Cequel::Metal, a query builder for CQL3 statements 23 | # @see Cequel::Schema Cequel::Schema::Keyspace, which provides full read-write 24 | # access to the database schema defined in Cassandra 25 | # 26 | module Cequel 27 | extend Cequel::Uuids 28 | # 29 | # Get a handle to a keyspace 30 | # 31 | # @param (see Metal::Keyspace#initialize) 32 | # @option (see Metal::Keyspace#initialize) 33 | # @return [Metal::Keyspace] a handle to a keyspace 34 | # 35 | def self.connect(configuration = nil) 36 | Metal::Keyspace.new(configuration || {}) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/examples/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require File.expand_path('../../environment', __FILE__) 3 | require 'cequel' 4 | require 'tzinfo' 5 | 6 | Dir.glob(File.expand_path('../../support/**/*.rb', __FILE__)).each do |file| 7 | require file 8 | end 9 | Dir.glob(File.expand_path('../../shared/**/*.rb', __FILE__)).each do |file| 10 | require file 11 | end 12 | 13 | RSpec.configure do |config| 14 | config.include(Cequel::SpecSupport::Helpers) 15 | config.extend(Cequel::SpecSupport::Macros) 16 | 17 | config.filter_run_excluding rails: ->(requirement) { 18 | !Gem::Requirement.new(requirement). 19 | satisfied_by?(Gem::Version.new(ActiveSupport::VERSION::STRING)) 20 | } 21 | 22 | unless defined? CassandraCQL 23 | config.filter_run_excluding thrift: true 24 | end 25 | 26 | config.before(:all) do 27 | cequel.schema.create! 28 | Cequel::Record.connection = cequel 29 | Time.zone = 'UTC' 30 | I18n.enforce_available_locales = false 31 | SafeYAML::OPTIONS[:default_mode] = :safe if defined? SafeYAML 32 | end 33 | 34 | config.after(:all) do 35 | cequel.schema.drop! 36 | end 37 | end 38 | 39 | if defined? byebug 40 | Kernel.module_eval { alias_method :debugger, :byebug } 41 | end 42 | -------------------------------------------------------------------------------- /lib/cequel/util.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Util 4 | # 5 | # @api private 6 | # 7 | module HashAccessors 8 | def hattr_reader(hash, *attributes) 9 | attributes.each do |attribute| 10 | module_eval <<-RUBY, __FILE__, __LINE__ + 1 11 | def #{attribute} 12 | #{hash}[#{attribute.to_sym.inspect}] 13 | end 14 | RUBY 15 | end 16 | end 17 | 18 | def hattr_inquirer(hash, *attributes) 19 | attributes.each do |attribute| 20 | module_eval <<-RUBY, __FILE__, __LINE__ + 1 21 | def #{attribute}? 22 | !!#{hash}[#{attribute.to_sym.inspect}] 23 | end 24 | RUBY 25 | end 26 | end 27 | 28 | def hattr_writer(hash, *attributes) 29 | attributes.each do |attribute| 30 | module_eval <<-RUBY, __FILE__, __LINE__ + 1 31 | def #{attribute}=(value) 32 | #{hash}[#{attribute.to_sym.inspect}] = value 33 | end 34 | RUBY 35 | end 36 | end 37 | 38 | def hattr_accessor(hash, *attributes) 39 | hattr_reader(hash, *attributes) 40 | hattr_writer(hash, *attributes) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /rubocop-todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by `rubocop --auto-gen-config`. 2 | # The point is for the user to remove these configuration records 3 | # one by one as the offences are removed from the code base. 4 | 5 | ClassLength: 6 | Enabled: false 7 | 8 | MethodCallParentheses: 9 | Enabled: false 10 | 11 | MethodLength: 12 | Max: 34 13 | 14 | MultilineTernaryOperator: 15 | Enabled: false 16 | 17 | PerlBackrefs: 18 | Enabled: false 19 | 20 | PredicateName: 21 | Enabled: false 22 | 23 | RedundantSelf: 24 | Enabled: false 25 | 26 | RescueException: 27 | Enabled: false 28 | 29 | ShadowingOuterLocalVariable: 30 | Enabled: false 31 | 32 | SignalException: 33 | Enabled: false 34 | 35 | SingleLineBlockParams: 36 | Enabled: false 37 | 38 | SingleLineMethods: 39 | Enabled: false 40 | 41 | SpaceAfterComma: 42 | Enabled: false 43 | 44 | SpaceAroundEqualsInParameterDefault: 45 | Enabled: false 46 | 47 | SpaceAroundOperators: 48 | Enabled: false 49 | 50 | SpaceInsideHashLiteralBraces: 51 | Enabled: false 52 | 53 | StringLiterals: 54 | Enabled: false 55 | 56 | TrailingBlankLines: 57 | Enabled: false 58 | 59 | TrailingWhitespace: 60 | Enabled: false 61 | 62 | TrivialAccessors: 63 | Enabled: false 64 | 65 | UselessAssignment: 66 | Enabled: false 67 | 68 | Void: 69 | Enabled: false 70 | 71 | WordArray: 72 | Enabled: false 73 | -------------------------------------------------------------------------------- /lib/cequel/record/mass_assignment.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | begin 3 | require 'active_model/forbidden_attributes_protection' 4 | rescue LoadError 5 | require 'active_model/mass_assignment_security' 6 | end 7 | 8 | module Cequel 9 | module Record 10 | # rubocop:disable LineLength 11 | 12 | # 13 | # Cequel supports mass-assignment protection in both the Rails 3 and Rails 14 | # 4 paradigms. Rails 3 applications may define `attr_protected` and 15 | # `attr_accessible` attributes in {Record} classes. In Rails 4, Cequel will 16 | # respect strong parameters. 17 | # 18 | # @see https://github.com/rails/strong_parameters Rails 4 Strong Parameters 19 | # @see 20 | # http://api.rubyonrails.org/v3.2.15/classes/ActiveModel/MassAssignmentSecurity.html 21 | # Rails 3 mass-assignment security 22 | # 23 | # @since 1.0.0 24 | # 25 | module MassAssignment 26 | # rubocop:enable LineLength 27 | extend ActiveSupport::Concern 28 | 29 | included do 30 | if defined? ActiveModel::ForbiddenAttributesProtection 31 | include ActiveModel::ForbiddenAttributesProtection 32 | else 33 | include ActiveModel::MassAssignmentSecurity 34 | end 35 | end 36 | 37 | # @private 38 | def attributes=(attributes) 39 | super(sanitize_for_mass_assignment(attributes)) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/cequel/uuids.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | # 4 | # This module adds some utility methods for generating and type-checking UUID 5 | # objects for use with Cequel. These methods are provided because the actual 6 | # UUID implementation is an artifact of the underlying driver; 7 | # initializing/typechecking those driver classes directly is potentially 8 | # breaking. 9 | # 10 | module Uuids 11 | # 12 | # Create a UUID 13 | # 14 | # @param value [Time,String,Integer] timestamp to assign to the UUID, or 15 | # numeric or string representation of the UUID 16 | # @return a UUID appropriate for use with Cequel 17 | # 18 | def uuid(value = nil) 19 | if value.nil? 20 | timeuuid_generator.next 21 | elsif value.is_a?(Time) 22 | timeuuid_generator.from_time(value) 23 | elsif value.is_a?(DateTime) 24 | timeuuid_generator.from_time(Time.at(value.to_f)) 25 | else 26 | Type::Timeuuid.instance.cast(value) 27 | end 28 | end 29 | 30 | # 31 | # Determine if an object is a UUID 32 | # 33 | # @param object an object to check 34 | # @return [Boolean] true if the object is recognized by Cequel as a UUID 35 | # 36 | def uuid?(object) 37 | object.is_a?(Cql::Uuid) 38 | end 39 | 40 | private 41 | 42 | def timeuuid_generator 43 | @timeuuid_generator ||= Cql::TimeUuid::Generator.new 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/cequel/metal/incrementer.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # Encapsulates a counter `UPDATE` operation comprising multiple increment 6 | # or decrement operations 7 | # 8 | # @see DataSet#increment 9 | # @since 1.0.0 10 | # 11 | class Incrementer < Writer 12 | # 13 | # Increment one or more columns by given deltas 14 | # 15 | # @param data [Hash] map of column names to deltas 16 | # @return [void] 17 | # 18 | def increment(data) 19 | data.each_pair do |column_name, delta| 20 | operator = delta < 0 ? '-' : '+' 21 | statements << "#{column_name} = #{column_name} #{operator} ?" 22 | bind_vars << delta.abs 23 | end 24 | end 25 | 26 | # 27 | # Decrement one or more columns by given deltas 28 | # 29 | # @param data [Hash] map of column names to deltas 30 | # @return [void] 31 | # 32 | def decrement(data) 33 | increment(Hash[data.map { |column, count| [column, -count] }]) 34 | end 35 | 36 | private 37 | 38 | def write_to_statement(statement, options) 39 | statement 40 | .append("UPDATE #{table_name}") 41 | .append(generate_upsert_options(options)) 42 | .append( 43 | " SET " << statements.join(', '), 44 | *bind_vars 45 | ) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/cequel/record/errors.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Raised when attempting to access an attribute of a record when that 6 | # attribute hasn't been loaded 7 | # 8 | # @since 1.0.0 9 | # 10 | MissingAttributeError = Class.new(ArgumentError) 11 | # 12 | # Raised when attempting to read or write an attribute that isn't defined 13 | # on the record 14 | # 15 | # @since 1.0.0 16 | # 17 | UnknownAttributeError = Class.new(ArgumentError) 18 | # 19 | # Raised when attempting to load a record by key when that record does not 20 | # exist 21 | # 22 | RecordNotFound = Class.new(StandardError) 23 | # 24 | # Raised when attempting to configure a record in a way that is not 25 | # possible 26 | # 27 | # @since 1.0.0 28 | # 29 | InvalidRecordConfiguration = Class.new(StandardError) 30 | # 31 | # Raised when attempting to save a record that is invalid 32 | # 33 | RecordInvalid = Class.new(StandardError) 34 | # 35 | # Raised when attempting to construct a {RecordSet} that cannot construct 36 | # a valid CQL query 37 | # 38 | # @since 1.0.0 39 | # 40 | IllegalQuery = Class.new(StandardError) 41 | # 42 | # Raised when attempting to persist a Cequel::Record without defining all 43 | # primary key columns 44 | # 45 | # @since 1.0.0 46 | # 47 | MissingKeyError = Class.new(StandardError) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/cequel/metal/row_specification.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # Encapsulates a row specification (`WHERE` clause) constructed from a 6 | # column ane one or more values to match 7 | # 8 | # @api private 9 | # 10 | class RowSpecification 11 | # 12 | # Build one or more row specifications 13 | # 14 | # @param column_values [Hash] map of column name to value or values 15 | # @return [Array] collection of row specifications 16 | # 17 | def self.build(column_values) 18 | column_values.map { |column, value| new(column, value) } 19 | end 20 | 21 | # @return [Symbol] column name 22 | attr_reader :column 23 | # @return [Object, Array] value or values to match 24 | attr_reader :value 25 | 26 | # 27 | # @param column [Symbol] column name 28 | # @param value [Object,Array] value or values to match 29 | # 30 | def initialize(column, value) 31 | @column, @value = column, value 32 | end 33 | 34 | # 35 | # @return [String] row specification as CQL fragment 36 | # 37 | def cql 38 | case @value 39 | when Array 40 | if @value.length == 1 41 | ["#{@column} = ?", @value.first] 42 | else 43 | ["#{@column} IN (?)", @value] 44 | end 45 | else 46 | ["#{@column} = ?", @value] 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /cequel.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/cequel/version', __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'cequel' 5 | s.version = Cequel::VERSION 6 | s.authors = [ 7 | 'Mat Brown', 'Aubrey Holland', 'Keenan Brock', 'Insoo Buzz Jung', 8 | 'Louis Simoneau', 'Peter Williams', 'Kenneth Hoffman', 'Antti Tapio', 9 | 'Ilya Bazylchuk', 'Dan Cardamore', 'Kei Kusakari' 10 | ] 11 | s.homepage = "https://github.com/cequel/cequel" 12 | s.email = 'mat.a.brown@gmail.com' 13 | s.license = 'MIT' 14 | s.summary = 'Full-featured, ActiveModel-compliant ORM for Cassandra using CQL3' 15 | s.description = <= 3.1', '< 5.0' 28 | s.add_runtime_dependency 'cql-rb', '~> 1.2' 29 | s.add_development_dependency 'appraisal', '~> 0.5' 30 | s.add_development_dependency 'rspec', '~> 2.0' 31 | s.add_development_dependency 'yard', '~> 0.6' 32 | s.add_development_dependency 'rake', '~> 10.1' 33 | s.add_development_dependency 'rubocop', '~> 0.19.0' 34 | s.requirements << 'Cassandra >= 1.2.0' 35 | end 36 | -------------------------------------------------------------------------------- /lib/cequel/record/callbacks.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Cequel::Record models provide lifecycle callbacks for `create`, `update`, 6 | # `save`, `destroy`, and `validation`. 7 | # 8 | # @example 9 | # class User 10 | # include Cequel::Record 11 | # 12 | # key :login, :text 13 | # column :name, :text 14 | # 15 | # after_create :send_welcome_email 16 | # after_update :reindex_posts_for_search 17 | # after_save :reindex_for_search 18 | # after_destroy :send_farewell_email 19 | # before_validation :set_permalink 20 | # end 21 | # 22 | # @since 0.1.0 23 | # 24 | module Callbacks 25 | extend ActiveSupport::Concern 26 | 27 | included do 28 | extend ActiveModel::Callbacks 29 | define_model_callbacks :save, :create, :update, :destroy 30 | end 31 | 32 | # (see Persistence#save) 33 | def save(options = {}) 34 | connection.batch(options.slice(:consistency)) do 35 | run_callbacks(:save) { super } 36 | end 37 | end 38 | 39 | # (see Persistence#destroy) 40 | def destroy(options = {}) 41 | connection.batch(options.slice(:consistency)) do 42 | run_callbacks(:destroy) { super } 43 | end 44 | end 45 | 46 | protected 47 | 48 | def create(*) 49 | run_callbacks(:create) { super } 50 | end 51 | 52 | def update(*) 53 | run_callbacks(:update) { super } 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/cequel/metal/statement.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # Builder for CQL statements. Contains a CQL string with bind substitutions 6 | # and a collection of bind variables 7 | # 8 | # @api private 9 | # 10 | class Statement 11 | # @return [Array] bind variables for CQL string 12 | attr_reader :bind_vars 13 | 14 | def initialize 15 | @cql, @bind_vars = [], [] 16 | end 17 | 18 | # 19 | # @return [String] CQL statement 20 | # 21 | def cql 22 | @cql.join 23 | end 24 | 25 | # 26 | # Add a CQL fragment with optional bind variables to the beginning of 27 | # the statement 28 | # 29 | # @param (see #append) 30 | # @return [void] 31 | # 32 | def prepend(cql, *bind_vars) 33 | @cql.unshift(cql) 34 | @bind_vars.unshift(*bind_vars) 35 | end 36 | 37 | # 38 | # Add a CQL fragment with optional bind variables to the end of the 39 | # statement 40 | # 41 | # @param cql [String] CQL fragment 42 | # @param bind_vars [Object] zero or more bind variables 43 | # @return [void] 44 | # 45 | def append(cql, *bind_vars) 46 | @cql << cql 47 | @bind_vars.concat(bind_vars) 48 | self 49 | end 50 | 51 | # 52 | # @return [Array] this statement as an array of arguments to 53 | # Keyspace#execute (CQL string followed by bind variables) 54 | # 55 | def args 56 | [cql, *bind_vars] 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/cequel/record/railtie.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require 'i18n/core_ext/hash' 3 | 4 | module Cequel 5 | module Record 6 | # @private 7 | # @since 0.1.0 8 | class Railtie < Rails::Railtie 9 | config.cequel = Record 10 | 11 | def self.app_name 12 | Rails.application.railtie_name.sub(/_application$/, '') 13 | end 14 | 15 | initializer "cequel.configure_rails" do 16 | config_path = Rails.root.join('config/cequel.yml').to_s 17 | 18 | if File.exist?(config_path) 19 | config = YAML.load(ERB.new(IO.read(config_path)).result)[Rails.env] 20 | .deep_symbolize_keys 21 | else 22 | config = {host: '127.0.0.1:9042'} 23 | end 24 | config.reverse_merge!(keyspace: "#{Railtie.app_name}_#{Rails.env}") 25 | connection = Cequel.connect(config) 26 | 27 | connection.logger = Rails.logger 28 | Record.connection = connection 29 | end 30 | 31 | initializer "cequel.add_new_relic" do 32 | begin 33 | require 'new_relic/agent/method_tracer' 34 | rescue LoadError => e 35 | Rails.logger.debug( 36 | "New Relic not installed; skipping New Relic integration") 37 | else 38 | require 'cequel/metal/new_relic_instrumentation' 39 | end 40 | end 41 | 42 | rake_tasks do 43 | require "cequel/record/tasks" 44 | end 45 | 46 | generators do 47 | require 'cequel/record/configuration_generator' 48 | require 'cequel/record/record_generator' 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/cequel/record/bulk_writes.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # This module implements bulk update and delete functionality for classes 6 | # that expose a collection of result rows. 7 | # 8 | # @abstract Including modules must implement `key_attributes_for_each_row`, 9 | # which should yield successive fully-specified key attributes for each 10 | # result row. 11 | # 12 | # @since 1.0.0 13 | # 14 | module BulkWrites 15 | # 16 | # Update all matched records with the given column values, without 17 | # executing callbacks. 18 | # 19 | # @param attributes [Hash] map of column names to values 20 | # @return [void] 21 | # 22 | def update_all(attributes) 23 | each_data_set { |data_set| data_set.update(attributes) } 24 | end 25 | 26 | # 27 | # Delete all matched records without executing callbacks 28 | # 29 | # @return [void] 30 | # 31 | def delete_all 32 | each_data_set { |data_set| data_set.delete } 33 | end 34 | 35 | # 36 | # Destroy all matched records, executing destroy callbacks for each 37 | # record. 38 | # 39 | # @return [void] 40 | # 41 | def destroy_all 42 | each { |record| record.destroy } 43 | end 44 | 45 | private 46 | 47 | def each_data_set 48 | key_attributes_for_each_row.each_slice(100) do |batch| 49 | connection.batch(unlogged: true) do 50 | batch.each { |key_attributes| yield table.where(key_attributes) } 51 | end 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/cequel/record/dirty.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Cequel provides support for dirty attribute tracking via ActiveModel. 6 | # Modifications to collection columns are registered by this mechanism. 7 | # 8 | # @see http://api.rubyonrails.org/classes/ActiveModel/Dirty.html Rails 9 | # documentation for ActiveModel::Dirty 10 | # 11 | # @since 0.1.0 12 | # 13 | module Dirty 14 | extend ActiveSupport::Concern 15 | 16 | included { include ActiveModel::Dirty } 17 | 18 | # @private 19 | module ClassMethods 20 | def key(name, *) 21 | define_attribute_method(name) 22 | super 23 | end 24 | 25 | def column(name, *) 26 | define_attribute_method(name) 27 | super 28 | end 29 | 30 | def set(name, *) 31 | define_attribute_method(name) 32 | super 33 | end 34 | 35 | def list(name, *) 36 | define_attribute_method(name) 37 | super 38 | end 39 | 40 | def map(name, *) 41 | define_attribute_method(name) 42 | super 43 | end 44 | end 45 | 46 | # @private 47 | def save(options = {}) 48 | super.tap do |success| 49 | if success 50 | @previously_changed = changes 51 | @changed_attributes.clear 52 | end 53 | end 54 | end 55 | 56 | private 57 | 58 | def write_attribute(name, value) 59 | if loaded? && value != read_attribute(name) 60 | __send__("#{name}_will_change!") 61 | end 62 | super 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/examples/record/mass_assignment_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require_relative 'spec_helper' 3 | 4 | describe Cequel::Record::MassAssignment do 5 | context 'with strong parameters', :rails => '~> 4.0' do 6 | model :Post do 7 | key :permalink, :text 8 | column :title, :text 9 | end 10 | 11 | it 'should allow assignment of vanilla hash' do 12 | Post.new(:title => 'Cequel').title.should == 'Cequel' 13 | end 14 | 15 | it 'should allow assignment of permitted strong params' do 16 | Post.new(StrongParams.new(true, :title => 'Cequel')).title. 17 | should == 'Cequel' 18 | end 19 | 20 | it 'should raise exception when assigned non-permitted strong params' do 21 | expect { Post.new(StrongParams.new(false, :title => 'Cequel')) }. 22 | to raise_error(ActiveModel::ForbiddenAttributesError) 23 | end 24 | 25 | class StrongParams < DelegateClass(Hash) 26 | def initialize(permitted, params) 27 | super(params) 28 | @permitted = !!permitted 29 | end 30 | 31 | def permitted? 32 | @permitted 33 | end 34 | end 35 | end 36 | 37 | context 'with mass-assignment protection', :rails => '~> 3.1' do 38 | model :Post do 39 | key :permalink, :text 40 | column :title, :text 41 | column :page_views, :int 42 | 43 | attr_accessible :title 44 | end 45 | 46 | let(:post) { Post.new(:title => 'Cequel', :page_views => 1000) } 47 | 48 | it 'should allow assignment of accessible params' do 49 | post.title.should == 'Cequel' 50 | end 51 | 52 | it 'should not allow assignment of inaccessible params' do 53 | post.page_views.should be_nil 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/cequel/record/scoped.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # All of the instance methods of {RecordSet} are also available as class 6 | # methods on {Record} implementations. 7 | # 8 | # @since 0.1.0 9 | # 10 | module Scoped 11 | extend ActiveSupport::Concern 12 | 13 | # 14 | # Scoping-related methods for {Record} classes 15 | # 16 | module ClassMethods 17 | extend Forwardable 18 | 19 | def_delegators :current_scope, 20 | *(RecordSet.public_instance_methods(false) + 21 | BulkWrites.public_instance_methods - 22 | Object.instance_methods - 23 | [:to_ary]) 24 | 25 | # @private 26 | def current_scope 27 | delegating_scope || RecordSet.new(self) 28 | end 29 | 30 | # @private 31 | def with_scope(record_set) 32 | previous_scope = delegating_scope 33 | self.delegating_scope = record_set 34 | yield 35 | ensure 36 | self.delegating_scope = previous_scope 37 | end 38 | 39 | protected 40 | 41 | def delegating_scope 42 | Thread.current[delegating_scope_key] 43 | end 44 | 45 | def delegating_scope=(delegating_scope) 46 | Thread.current[delegating_scope_key] = delegating_scope 47 | end 48 | 49 | def delegating_scope_key 50 | @delegating_scope_key ||= :"#{name}::delegating_scope" 51 | end 52 | end 53 | 54 | # @private 55 | def assert_fully_specified! 56 | self 57 | end 58 | 59 | private 60 | 61 | def initialize_new_record(*) 62 | super 63 | @attributes.merge!(self.class.current_scope.scoped_key_attributes) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/cequel/metal/inserter.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # Encapsulates an `INSERT` statement 6 | # 7 | # @see DataSet#insert 8 | # @since 1.0.0 9 | # 10 | class Inserter < Writer 11 | # 12 | # (see Writer#initialize) 13 | # 14 | def initialize(data_set) 15 | @row = {} 16 | super 17 | end 18 | 19 | # 20 | # (see Writer#execute) 21 | # 22 | def execute(options = {}) 23 | statement = Statement.new 24 | consistency = options.fetch(:consistency, data_set.query_consistency) 25 | write_to_statement(statement, options) 26 | data_set.write_with_consistency( 27 | statement.cql, statement.bind_vars, consistency) 28 | end 29 | 30 | # 31 | # Insert the given data into the table 32 | # 33 | # @param data [Hash] map of column names to values 34 | # @return [void] 35 | # 36 | def insert(data) 37 | @row.merge!(data.symbolize_keys) 38 | end 39 | 40 | private 41 | 42 | attr_reader :row 43 | 44 | def column_names 45 | row.keys 46 | end 47 | 48 | def statements 49 | [].tap do |statements| 50 | row.each_pair do |column_name, value| 51 | column_names << column_name 52 | prepare_upsert_value(value) do |statement, *values| 53 | statements << statement 54 | bind_vars.concat(values) 55 | end 56 | end 57 | end 58 | end 59 | 60 | def write_to_statement(statement, options) 61 | statement.append("INSERT INTO #{table_name}") 62 | statement.append( 63 | " (#{column_names.join(', ')}) VALUES (#{statements.join(', ')}) ", 64 | *bind_vars) 65 | statement.append(generate_upsert_options(options)) 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/cequel/metal/request_logger.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # The Logger class encapsulates logging functionality for {Keyspace}. 6 | # 7 | # @api private 8 | # 9 | class RequestLogger 10 | extend Forwardable 11 | # @return [::Logger] An instance of Logger that responds to methods for 12 | # standard severity levels 13 | attr_accessor :logger 14 | # @return [Integer] Only log queries that take longer than threshold ms 15 | attr_accessor :slowlog_threshold 16 | 17 | def initialize 18 | self.slowlog_threshold = 2000 19 | end 20 | 21 | # 22 | # Log a CQL statement 23 | # 24 | # @param label [String] a logical label for this statement 25 | # @param statement [String] the CQL statement to log 26 | # @param bind_vars bind variables for the CQL statement 27 | # @return [void] 28 | # 29 | def log(label, statement, *bind_vars) 30 | return yield if logger.nil? 31 | 32 | response = nil 33 | begin 34 | time = Benchmark.ms { response = yield } 35 | generate_message = lambda do 36 | format_for_log(label, "#{time.round.to_i}ms", statement, bind_vars) 37 | end 38 | 39 | if time >= slowlog_threshold 40 | logger.warn(&generate_message) 41 | else 42 | logger.debug(&generate_message) 43 | end 44 | rescue Exception => e 45 | logger.error { format_for_log(label, 'ERROR', statement, bind_vars) } 46 | raise 47 | end 48 | response 49 | end 50 | 51 | private 52 | 53 | def format_for_log(label, timing, statement, bind_vars) 54 | format('%s (%s) %s', label, timing, sanitize(statement, bind_vars)) 55 | end 56 | 57 | def_delegator 'Cequel::Metal::Keyspace', :sanitize 58 | private :sanitize 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/cequel/record/belongs_to_association.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Represents a parent association declared by 6 | # {Associations::ClassMethods#belongs_to belongs_to} 7 | # 8 | # @see Associations::ClassMethods#parent_association 9 | # @since 1.0.0 10 | # 11 | class BelongsToAssociation 12 | extend Forwardable 13 | 14 | # @return [Class] child class that declared `belongs_to` 15 | attr_reader :owner_class 16 | # @return [Symbol] name of the association 17 | attr_reader :name 18 | # @return [String] name of parent class 19 | attr_reader :association_class_name 20 | 21 | # @!attribute [r] association_key_columns 22 | # @return [Array] key columns on the parent class 23 | def_delegator :association_class, :key_columns, :association_key_columns 24 | 25 | # 26 | # @param owner_class [Class] child class that declared `belongs_to` 27 | # @param name [Symbol] name of the association 28 | # @param options [Options] options for association 29 | # @option options [String] :class_name name of parent class 30 | # 31 | # @api private 32 | # 33 | def initialize(owner_class, name, options = {}) 34 | options.assert_valid_keys(:class_name) 35 | 36 | @owner_class, @name = owner_class, name.to_sym 37 | @association_class_name = 38 | options.fetch(:class_name, @name.to_s.classify) 39 | end 40 | 41 | # 42 | # @return [Class] parent class declared by `belongs_to` 43 | # 44 | def association_class 45 | @association_class ||= association_class_name.constantize 46 | end 47 | 48 | # 49 | # @return [Symbol] instance variable name to use for storing the parent 50 | # instance in a record 51 | # 52 | # @api private 53 | # 54 | def instance_variable_name 55 | @instance_variable_name ||= :"@#{name}" 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/examples/record/dirty_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require File.expand_path('../spec_helper', __FILE__) 3 | 4 | describe Cequel::Record::Dirty do 5 | model :Post do 6 | key :permalink, :text 7 | column :title, :text 8 | set :categories, :text 9 | end 10 | 11 | context 'loaded model' do 12 | let(:post) do 13 | Post.create!( 14 | permalink: 'cequel', 15 | title: 'Cequel', 16 | categories: Set['Libraries'] 17 | ) 18 | end 19 | 20 | it 'should not have changed attributes by default' do 21 | post.changed_attributes.should be_empty 22 | end 23 | 24 | it 'should have changed attributes if attributes change' do 25 | post.title = 'Cequel ORM' 26 | post.changed_attributes. 27 | should == {:title => 'Cequel'}.with_indifferent_access 28 | end 29 | 30 | it 'should not have changed attributes if attribute set to the same thing' do 31 | post.title = 'Cequel' 32 | post.changed_attributes.should be_empty 33 | end 34 | 35 | it 'should support *_changed? method' do 36 | post.title = 'Cequel ORM' 37 | post.title_changed?.should be_true 38 | end 39 | 40 | it 'should not have changed attributes after save' do 41 | post.title = 'Cequel ORM' 42 | post.save 43 | post.changed_attributes.should be_empty 44 | end 45 | 46 | it 'should have previous changes after save' do 47 | post.title = 'Cequel ORM' 48 | post.save 49 | post.previous_changes. 50 | should == { :title => ['Cequel', 'Cequel ORM'] }.with_indifferent_access 51 | end 52 | 53 | it 'should detect changes to collections' do 54 | post.categories << 'Gems' 55 | post.changes.should == 56 | {categories: [Set['Libraries'], Set['Libraries', 'Gems']]}. 57 | with_indifferent_access 58 | end 59 | end 60 | 61 | context 'unloaded model' do 62 | let(:post) { Post['cequel'] } 63 | 64 | it 'should not track changes' do 65 | post.title = 'Cequel' 66 | post.changes.should be_empty 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/cequel/metal/row.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # A result row from a CQL query. Acts as a hash of column names to values, 6 | # but also exposes TTLs and writetimes 7 | # 8 | # @since 1.0.0 9 | # 10 | class Row < DelegateClass(ActiveSupport::HashWithIndifferentAccess) 11 | # 12 | # Encapsulate a result row from the driver 13 | # 14 | # @param result_row [Hash] row from underlying driver 15 | # @return [Row] encapsulated row 16 | # 17 | # @api private 18 | # 19 | def self.from_result_row(result_row) 20 | if result_row 21 | new.tap do |row| 22 | result_row.each_pair do |name, value| 23 | if name =~ /^(ttl|writetime)\((.+)\)$/ 24 | if $1 == 'ttl' then row.set_ttl($2, value) 25 | else row.set_writetime($2, value) 26 | end 27 | else row[name] = value 28 | end 29 | end 30 | end 31 | end 32 | end 33 | 34 | # 35 | # @api private 36 | # 37 | def initialize 38 | super(ActiveSupport::HashWithIndifferentAccess.new) 39 | @ttls = ActiveSupport::HashWithIndifferentAccess.new 40 | @writetimes = ActiveSupport::HashWithIndifferentAccess.new 41 | end 42 | 43 | # 44 | # Get the TTL (time-to-live) of a column 45 | # 46 | # @param column [Symbol] column name 47 | # @return [Integer] TTL of column in seconds 48 | # 49 | def ttl(column) 50 | @ttls[column] 51 | end 52 | 53 | # 54 | # Get the writetime of a column 55 | # 56 | # @param column [Symbol] column name 57 | # @return [Integer] writetime of column in nanoseconds since epoch 58 | # 59 | def writetime(column) 60 | @writetimes[column] 61 | end 62 | 63 | # @private 64 | def set_ttl(column, value) 65 | @ttls[column] = value 66 | end 67 | 68 | # @private 69 | def set_writetime(column, value) 70 | @writetimes[column] = value 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/cequel/record/tasks.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | namespace :cequel do 3 | namespace :keyspace do 4 | desc 'Initialize Cassandra keyspace' 5 | task :create => :environment do 6 | Cequel::Record.connection.schema.create! 7 | puts "Created keyspace #{Cequel::Record.connection.name}" 8 | end 9 | 10 | desc 'Drop Cassandra keyspace' 11 | task :drop => :environment do 12 | Cequel::Record.connection.schema.drop! 13 | puts "Dropped keyspace #{Cequel::Record.connection.name}" 14 | end 15 | end 16 | 17 | desc "Synchronize all models defined in `app/models' with Cassandra " \ 18 | "database schema" 19 | task :migrate => :environment do 20 | watch_stack = ActiveSupport::Dependencies::WatchStack.new 21 | 22 | migration_table_names = Set[] 23 | models_dir_path = "#{Rails.root.join('app', 'models')}/" 24 | model_files = Dir.glob(Rails.root.join('app', 'models', '**', '*.rb')) 25 | model_files.sort.each do |file| 26 | watch_namespaces = ["Object"] 27 | model_file_name = file.sub(/^#{Regexp.escape(models_dir_path)}/, "") 28 | dirname = File.dirname(model_file_name) 29 | watch_namespaces << dirname.classify unless dirname == "." 30 | watch_stack.watch_namespaces(watch_namespaces) 31 | require_dependency(file) 32 | 33 | new_constants = watch_stack.new_constants 34 | if new_constants.empty? 35 | new_constants << model_file_name.sub(/\.rb$/, "").classify 36 | end 37 | 38 | new_constants.each do |class_name| 39 | begin 40 | clazz = class_name.constantize 41 | rescue NameError # rubocop:disable HandleExceptions 42 | else 43 | if clazz.ancestors.include?(Cequel::Record) && 44 | !migration_table_names.include?(clazz.table_name.to_sym) 45 | clazz.synchronize_schema 46 | migration_table_names << clazz.table_name.to_sym 47 | puts "Synchronized schema for #{class_name}" 48 | end 49 | end 50 | end 51 | end 52 | end 53 | 54 | desc "Create keyspace and tables for all defined models" 55 | task :init => %w(keyspace:create migrate) 56 | end 57 | -------------------------------------------------------------------------------- /lib/cequel/record/has_many_association.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Represents a child association declared by 6 | # {Associations::ClassMethods#has_many has_many}. 7 | # 8 | # @see Associations::ClassMethods#child_associations 9 | # @since 1.0.0 10 | # 11 | class HasManyAssociation 12 | # @return [Class] Record class that declares this association 13 | attr_reader :owner_class 14 | # @return [Symbol] name of this association 15 | attr_reader :name 16 | # @return [Symbol] name of the child class that this association contains 17 | attr_reader :association_class_name 18 | # @return [Boolean] behavior for propagating destruction from parent to 19 | # children 20 | attr_reader :dependent 21 | 22 | # 23 | # @param owner_class [Class] Record class that declares this association 24 | # @param name [Symbol] name of the association 25 | # @param options [Options] options for the association 26 | # @option options [Symbol] :class_name name of the child class 27 | # @option options [Boolean] :dependent propagation behavior for destroy 28 | # 29 | # @api private 30 | # 31 | def initialize(owner_class, name, options = {}) 32 | options.assert_valid_keys(:class_name, :dependent) 33 | 34 | @owner_class, @name = owner_class, name 35 | @association_class_name = 36 | options.fetch(:class_name, name.to_s.classify) 37 | case options[:dependent] 38 | when :destroy, :delete, nil 39 | @dependent = options[:dependent] 40 | else 41 | fail ArgumentError, 42 | "Invalid :dependent option #{options[:dependent].inspect}. " \ 43 | "Valid values are :destroy, :delete" 44 | end 45 | end 46 | 47 | # 48 | # @return [Class] class of child association 49 | # 50 | def association_class 51 | @association_class ||= association_class_name.constantize 52 | end 53 | 54 | # @private 55 | def instance_variable_name 56 | @instance_variable_name ||= :"@#{name}" 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/cequel/metal/batch_manager.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # Manage a current batch per thread. Used by {Keyspace} 6 | # 7 | # @api private 8 | # 9 | class BatchManager 10 | # 11 | # @param keyspace [Keyspace] keyspace to make writes to 12 | # @api private 13 | # 14 | def initialize(keyspace) 15 | @keyspace = keyspace 16 | end 17 | 18 | # 19 | # Execute write operations in a batch. Any inserts, updates, and deletes 20 | # inside this method's block will be executed inside a CQL BATCH 21 | # operation. 22 | # 23 | # @param options [Hash] 24 | # @option (see Batch#initialize) 25 | # @yield context within which all write operations will be batched 26 | # @return return value of block 27 | # @raise [ArgumentError] if attempting to start a logged batch while 28 | # already in an unlogged batch, or vice versa. 29 | # 30 | # @example Perform inserts in a batch 31 | # DB.batch do 32 | # DB[:posts].insert(:id => 1, :title => 'One') 33 | # DB[:posts].insert(:id => 2, :title => 'Two') 34 | # end 35 | # 36 | # @note If this method is created while already in a batch of the same 37 | # type (logged or unlogged), this method is a no-op. 38 | # 39 | def batch(options = {}) 40 | new_batch = Batch.new(keyspace, options) 41 | 42 | if current_batch 43 | if current_batch.unlogged? && new_batch.logged? 44 | fail ArgumentError, 45 | "Already in an unlogged batch; can't start a logged batch." 46 | end 47 | return yield 48 | end 49 | 50 | begin 51 | self.current_batch = new_batch 52 | yield.tap { new_batch.apply } 53 | ensure 54 | self.current_batch = nil 55 | end 56 | end 57 | 58 | private 59 | 60 | attr_reader :keyspace 61 | 62 | def current_batch 63 | ::Thread.current[batch_key] 64 | end 65 | 66 | def current_batch=(batch) 67 | ::Thread.current[batch_key] = batch 68 | end 69 | 70 | def batch_key 71 | :"cequel-batch-#{object_id}" 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/cequel/metal/deleter.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # DSL for the construction of a DELETE statement comprising multiple 6 | # operations (e.g. deleting a column value, deleting an element from a 7 | # list, etc.) 8 | # 9 | # 10 | # @note This class should not be instantiated directly 11 | # @see DataSet#delete 12 | # @see 13 | # http://cassandra.apache.org/doc/cql3/CQL.html#deleteStmt 14 | # CQL documentation for DELETE 15 | # @since 1.0.0 16 | # 17 | class Deleter < Writer 18 | # 19 | # Delete the entire row or rows matched by the data set 20 | # 21 | # @return [void] 22 | # 23 | def delete_row 24 | @delete_row = true 25 | end 26 | 27 | # 28 | # Delete specified columns 29 | # 30 | # @param columns [Symbol] column names to delete 31 | # @return [void] 32 | # 33 | def delete_columns(*columns) 34 | statements.concat(columns) 35 | end 36 | 37 | # 38 | # Remove elements from a list by position 39 | # 40 | # @param column [Symbol] name of list column 41 | # @param positions [Integer] positions in list from which to delete 42 | # elements 43 | # @return [void] 44 | # 45 | def list_remove_at(column, *positions) 46 | statements 47 | .concat(positions.map { |position| "#{column}[#{position}]" }) 48 | end 49 | 50 | # 51 | # Remote elements from a map by key 52 | # 53 | # @param column [Symbol] name of map column 54 | # @param keys [Object] keys to delete from map 55 | # @return [void] 56 | # 57 | def map_remove(column, *keys) 58 | statements.concat(keys.length.times.map { "#{column}[?]" }) 59 | bind_vars.concat(keys) 60 | end 61 | 62 | private 63 | 64 | def write_to_statement(statement, options) 65 | if @delete_row 66 | statement.append("DELETE FROM #{table_name}") 67 | elsif statements.empty? 68 | fail ArgumentError, "No targets given for deletion!" 69 | else 70 | statement.append("DELETE ") 71 | .append(statements.join(','), *bind_vars) 72 | .append(" FROM #{table_name}") 73 | end 74 | statement.append(generate_upsert_options(options)) 75 | end 76 | 77 | def empty? 78 | super && !@delete_row 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/examples/metal/keyspace_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require_relative '../spec_helper' 3 | 4 | describe Cequel::Metal::Keyspace do 5 | before :all do 6 | cequel.schema.create_table(:posts) do 7 | key :id, :int 8 | column :title, :text 9 | column :body, :text 10 | end 11 | end 12 | 13 | after :each do 14 | ids = cequel[:posts].select(:id).map { |row| row[:id] } 15 | cequel[:posts].where(id: ids).delete if ids.any? 16 | end 17 | 18 | after :all do 19 | cequel.schema.drop_table(:posts) 20 | end 21 | 22 | describe '::batch' do 23 | it 'should send enclosed write statements in bulk' do 24 | expect(cequel).to receive(:execute).once.and_call_original 25 | cequel.batch do 26 | cequel[:posts].insert(id: 1, title: 'Hey') 27 | cequel[:posts].where(id: 1).update(body: 'Body') 28 | cequel[:posts].where(id: 1).delete(:title) 29 | end 30 | RSpec::Mocks.proxy_for(cequel).reset 31 | expect(cequel[:posts].first).to eq({id: 1, title: nil, body: 'Body'} 32 | .with_indifferent_access) 33 | end 34 | 35 | it 'should auto-apply if option given' do 36 | cequel.batch(auto_apply: 2) do 37 | cequel[:posts].insert(id: 1, title: 'One') 38 | expect(cequel[:posts].count).to be_zero 39 | cequel[:posts].insert(id: 2, title: 'Two') 40 | expect(cequel[:posts].count).to be(2) 41 | end 42 | end 43 | 44 | it 'should do nothing if no statements executed in batch' do 45 | expect { cequel.batch {} }.to_not raise_error 46 | end 47 | 48 | it 'should execute unlogged batch if specified' do 49 | expect_query_with_consistency(/BEGIN UNLOGGED BATCH/, anything) do 50 | cequel.batch(unlogged: true) do 51 | cequel[:posts].insert(id: 1, title: 'One') 52 | cequel[:posts].insert(id: 2, title: 'Two') 53 | end 54 | end 55 | end 56 | 57 | it 'should execute batch with given consistency' do 58 | expect_query_with_consistency(/BEGIN BATCH/, :one) do 59 | cequel.batch(consistency: :one) do 60 | cequel[:posts].insert(id: 1, title: 'One') 61 | cequel[:posts].insert(id: 2, title: 'Two') 62 | end 63 | end 64 | end 65 | 66 | it 'should raise error if consistency specified in individual query in batch' do 67 | expect { 68 | cequel.batch(consistency: :one) do 69 | cequel[:posts].consistency(:quorum).insert(id: 1, title: 'One') 70 | end 71 | }.to raise_error(ArgumentError) 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/cequel/schema/create_table_dsl.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Schema 4 | # 5 | # Implements a DSL that can be used to define a table schema 6 | # 7 | # @see Keyspace#create_table 8 | # 9 | class CreateTableDSL < BasicObject 10 | extend ::Forwardable 11 | # 12 | # Evaluate `block` in the context of this DSL, and apply directives to 13 | # `table` 14 | # 15 | # @param table [Table] a table 16 | # @yield block evaluated in the context of the create table DSL 17 | # @return [void] 18 | # 19 | # @api private 20 | # 21 | def self.apply(table, &block) 22 | dsl = new(table) 23 | dsl.instance_eval(&block) 24 | end 25 | 26 | # 27 | # @param table [Table] table to apply directives to 28 | # 29 | # @api private 30 | # 31 | def initialize(table) 32 | @table = table 33 | end 34 | 35 | # 36 | # @!method partition_key(name, type) 37 | # (see Cequel::Schema::Table#add_partition_key) 38 | # 39 | def_delegator :@table, :add_partition_key, :partition_key 40 | 41 | # 42 | # @!method key(name, type, clustering_order = nil) 43 | # (see Cequel::Schema::Table#add_key) 44 | # 45 | def_delegator :@table, :add_key, :key 46 | 47 | # 48 | # @!method column(name, type, options = {}) 49 | # (see Cequel::Schema::Table#add_data_column) 50 | # 51 | def_delegator :@table, :add_data_column, :column 52 | 53 | # 54 | # @!method list(name, type) 55 | # (see Cequel::Schema::Table#add_list) 56 | # 57 | def_delegator :@table, :add_list, :list 58 | 59 | # 60 | # @!method set(name, type) 61 | # (see Cequel::Schema::Table#add_set) 62 | # 63 | def_delegator :@table, :add_set, :set 64 | 65 | # 66 | # @!method map(name, key_type, value_type) 67 | # (see Cequel::Schema::Table#add_map) 68 | # 69 | def_delegator :@table, :add_map, :map 70 | 71 | # 72 | # @!method with(name, value) 73 | # (see Cequel::Schema::Table#add_property) 74 | # 75 | def_delegator :@table, :add_property, :with 76 | 77 | # 78 | # Direct that this table use "compact storage". This is primarily useful 79 | # for backwards compatibility with legacy CQL2 table schemas. 80 | # 81 | # @return [void] 82 | # 83 | def compact_storage 84 | @table.compact_storage = true 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute # 2 | 3 | Contributions to Cequel are highly welcome! Here's a quick guide. 4 | 5 | ## Submitting a change ## 6 | 7 | 1. Fork the repo and create a topic branch 8 | 2. Set up your environment and run the tests. The easiest way to do this is to 9 | use Vagrant; see below. For those who already have a suitable Cassandra 10 | instance running locally: `rake test` 11 | 3. Add tests for your change. 12 | 4. Make the tests pass. 13 | 5. Push to your topic branch and submit a pull request. 14 | 15 | ### Do's and don'ts ### 16 | 17 | * **Do** write tests. If you don't test your patch, I'll have to write tests 18 | for it, which is likely to delay the pull request getting accepted. 19 | * **Do** write documentation for new functionality and update documentation for 20 | changed functionality. Cequel uses 21 | [YARD](http://rubydoc.info/gems/yard/file/docs/GettingStarted.md) for 22 | documentation. If you're adding a significant new feature, update the 23 | `README` too. 24 | * **Do** use code style consistent with the project. Cequel by and large 25 | follows the [Ruby Style Guide](https://github.com/bbatsov/ruby-style-guide). 26 | * **Don't** make changes to the `cequel.gemspec` or `version.rb` files, except 27 | to add new dependencies in the former. 28 | 29 | ## Running the tests ## 30 | 31 | ### For the impatient ### 32 | 33 | ```bash 34 | $ git clone git@github.com:yourname/cequel.git 35 | $ cd cequel 36 | $ git remote add upstream git@github.com:cequel/cequel.git 37 | $ brew tap phinze/cask 38 | $ brew install brew-cask 39 | $ brew cask install virtualbox vagrant 40 | $ vagrant up 2.0.4 41 | $ rake test 42 | ``` 43 | 44 | ### Using Vagrant 45 | 46 | Cequel's test suite runs against a live Cassandra instance. The easiest way to 47 | get one is to use the `Vagrantfile` included in the repo. You'll need to 48 | install [VirtualBox](https://www.virtualbox.org/) and 49 | [Vagrant](http://www.vagrantup.com/); both are available via 50 | [Homebrew-cask](https://github.com/phinze/homebrew-cask) if you're on OS X. 51 | 52 | Cequel's Vagrantfile can generate a virtual machine for any Cassandra version 53 | that Cequel supports (i.e., 1.2.x and 2.0.x). You can run multiple VMs at the 54 | same time; the first machine you boot will expose its Cassandra instance on 55 | port `9042`, which is the default port that Cequel will look for. 56 | 57 | Cequel is tested against a large range of Ruby, Rails, and Cassandra versions; 58 | for most patches, you can just run the tests using the latest version of all of 59 | them. If you're messing with the `Cequel::Schema` or `Cequel::Type` modules, 60 | you'll want to test at least against an early 1.2 release (1.2.4 is good), a 61 | later 1.2 release (1.2.13), and the latest 2.0 release. 62 | 63 | ## And finally 64 | 65 | **THANK YOU!** 66 | -------------------------------------------------------------------------------- /lib/cequel/record/validations.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # {Record} classes can define validations that are run before saving a 6 | # record instance. 7 | # 8 | # @example Validations 9 | # class User 10 | # include Cequel::Record 11 | # 12 | # key :login, :text 13 | # column :email, :text 14 | # 15 | # validates :email, presence: true, format: RFC822::EMAIL 16 | # end 17 | # 18 | # @see http://api.rubyonrails.org/classes/ActiveModel/Validations.html 19 | # ActiveModel::Validations 20 | # 21 | # @since 0.1.0 22 | # 23 | module Validations 24 | extend ActiveSupport::Concern 25 | 26 | included do 27 | include ActiveModel::Validations 28 | define_model_callbacks :validation 29 | alias_method_chain :valid?, :callbacks 30 | end 31 | 32 | # 33 | # Validation-related methods exposed on Record class singletons 34 | # 35 | module ClassMethods 36 | # 37 | # Attempt to create a new record, or raise an exception otherwise 38 | # 39 | # @param (see Persistence::ClassMethods#create) 40 | # @yieldparam (see Persistence::ClassMethods#create) 41 | # @return (see Persistence::ClassMethods#create) 42 | # @raise (see Validations#save!) 43 | # 44 | def create!(attributes = {}, &block) 45 | new(attributes, &block).save! 46 | end 47 | end 48 | 49 | # @private 50 | def save(options = {}) 51 | validate = options.fetch(:validate, true) 52 | options.delete(:validate) 53 | (!validate || valid?) && super 54 | end 55 | 56 | # 57 | # Attempt to save the record, or raise an exception if there is a 58 | # validation error 59 | # 60 | # @param (see Persistence#save) 61 | # @return [Record] self 62 | # @raise [RecordInvalid] if there are validation errors 63 | # 64 | def save!(attributes = {}) 65 | tap do 66 | unless save(attributes) 67 | fail RecordInvalid, errors.full_messages.join("; ") 68 | end 69 | end 70 | end 71 | 72 | # 73 | # Set the given attributes and attempt to save, raising an exception if 74 | # there is a validation error 75 | # 76 | # @param (see Persistence#update_attributes) 77 | # @return (see #save!) 78 | # @raise (see #save!) 79 | def update_attributes!(attributes) 80 | self.attributes = attributes 81 | save! 82 | end 83 | 84 | private 85 | 86 | def valid_with_callbacks?(context=nil) 87 | run_callbacks(:validation) { valid_without_callbacks? context } 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/cequel/schema/update_table_dsl.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Schema 4 | # 5 | # DSL for describing a series of schema modification statements 6 | # 7 | class UpdateTableDSL < BasicObject 8 | extend ::Forwardable 9 | # 10 | # Describe a series of schema modifications and build a {TableUpdater} 11 | # to encapsulate them 12 | # 13 | # @param (see #initialize) 14 | # @yield a block evaluated in the context of an {UpdateTableDSL} instance 15 | # @return [void] 16 | # 17 | # @api private 18 | # @see Keyspace#update_table 19 | # 20 | def self.apply(updater, &block) 21 | dsl = new(updater) 22 | dsl.instance_eval(&block) 23 | end 24 | 25 | # 26 | # @param updater [TableUpdater] 27 | # 28 | # @api private 29 | # 30 | def initialize(updater) 31 | @updater = updater 32 | end 33 | 34 | # 35 | # @!method add_column(name, type) 36 | # (see Cequel::Schema::TableUpdater#add_column) 37 | # 38 | def_delegator :@updater, :add_column 39 | 40 | # 41 | # @!method add_list(name, type) 42 | # (see Cequel::Schema::TableUpdater#add_list) 43 | # 44 | def_delegator :@updater, :add_list 45 | 46 | # 47 | # @!method add_set(name, type) 48 | # (see Cequel::Schema::TableUpdater#add_set) 49 | # 50 | def_delegator :@updater, :add_set 51 | 52 | # 53 | # @!method add_map(name, key_type, value_type) 54 | # (see Cequel::Schema::TableUpdater#add_map) 55 | # 56 | def_delegator :@updater, :add_map 57 | 58 | # 59 | # @!method change_column(name, type) 60 | # (see Cequel::Schema::TableUpdater#change_column) 61 | # 62 | def_delegator :@updater, :change_column 63 | 64 | # 65 | # @!method rename_column(old_name, new_name) 66 | # (see Cequel::Schema::TableUpdater#rename_column) 67 | # 68 | def_delegator :@updater, :rename_column 69 | 70 | # 71 | # @!method change_properties(options) 72 | # (see Cequel::Schema::TableUpdater#change_properties) 73 | # 74 | def_delegator :@updater, :change_properties 75 | alias_method :change_options, :change_properties 76 | 77 | # 78 | # @!method create_index(column_name, index_name = nil) 79 | # (see Cequel::Schema::TableUpdater#create_index 80 | # 81 | def_delegator :@updater, :create_index 82 | alias_method :add_index, :create_index 83 | 84 | # 85 | # @!method drop_index(index_name) 86 | # (see Cequel::Schema::TableUpdater#drop_index) 87 | # 88 | def_delegator :@updater, :drop_index 89 | alias_method :remove_index, :drop_index 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/cequel/record/data_set_builder.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # This is a utility class to construct a {Metal::DataSet} for a given 6 | # {RecordSet}. 7 | # 8 | # @api private 9 | # 10 | class DataSetBuilder 11 | extend Forwardable 12 | 13 | # 14 | # Build a data set for the given record set 15 | # 16 | # @param (see #initialize) 17 | # @return (see #build) 18 | # 19 | def self.build_for(record_set) 20 | new(record_set).build 21 | end 22 | 23 | # 24 | # @param record_set [RecordSet] record set for which to construct data 25 | # set 26 | # 27 | def initialize(record_set) 28 | @record_set = record_set 29 | @data_set = record_set.connection[record_set.target_class.table_name] 30 | end 31 | private_class_method :new 32 | 33 | # 34 | # @return [Metal::DataSet] a DataSet exposing the rows for the record set 35 | # 36 | def build 37 | add_limit 38 | add_select_columns 39 | add_where_statement 40 | add_bounds 41 | add_order 42 | set_consistency 43 | data_set 44 | end 45 | 46 | protected 47 | 48 | attr_accessor :data_set 49 | attr_reader :record_set 50 | def_delegators :record_set, :row_limit, :select_columns, 51 | :scoped_key_names, :scoped_key_values, 52 | :scoped_indexed_column, :lower_bound, 53 | :upper_bound, :reversed?, :order_by_column, 54 | :query_consistency 55 | 56 | private 57 | 58 | def add_limit 59 | self.data_set = data_set.limit(row_limit) if row_limit 60 | end 61 | 62 | def add_select_columns 63 | self.data_set = data_set.select(*select_columns) if select_columns 64 | end 65 | 66 | def add_where_statement 67 | if scoped_key_values 68 | key_conditions = Hash[scoped_key_names.zip(scoped_key_values)] 69 | self.data_set = data_set.where(key_conditions) 70 | end 71 | if scoped_indexed_column 72 | self.data_set = data_set.where(scoped_indexed_column) 73 | end 74 | end 75 | 76 | def add_bounds 77 | if lower_bound 78 | self.data_set = 79 | data_set.where(*lower_bound.to_cql_with_bind_variables) 80 | end 81 | if upper_bound 82 | self.data_set = 83 | data_set.where(*upper_bound.to_cql_with_bind_variables) 84 | end 85 | end 86 | 87 | def add_order 88 | self.data_set = data_set.order(order_by_column => :desc) if reversed? 89 | end 90 | 91 | def set_consistency 92 | if query_consistency 93 | self.data_set = data_set.consistency(query_consistency) 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/cequel/record/lazy_record_collection.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Encapsulates a collection of unloaded {Record} instances. In the case 6 | # where a record set is scoped to fully specify the keys of multiple 7 | # records, those records will be returned unloaded in a 8 | # LazyRecordCollection. When an attribute is read from any of the records 9 | # in a LazyRecordCollection, it will eagerly load all of the records' rows 10 | # from the database. 11 | # 12 | # @since 1.0.0 13 | # 14 | class LazyRecordCollection < DelegateClass(Array) 15 | extend Forwardable 16 | include BulkWrites 17 | # 18 | # @!method table 19 | # (see RecordSet#table) 20 | # @!method connection 21 | # (see RecordSet#connection) 22 | def_delegators :record_set, :table, :connection 23 | 24 | # 25 | # @param record_set [RecordSet] record set representing the records in 26 | # this collection 27 | # @api private 28 | # 29 | def initialize(record_set) 30 | fail ArgumentError if record_set.nil? 31 | @record_set = record_set 32 | 33 | exploded_key_attributes = [{}].tap do |all_key_attributes| 34 | key_columns.zip(scoped_key_values) do |column, values| 35 | all_key_attributes.replace(Array(values).flat_map do |value| 36 | all_key_attributes.map do |key_attributes| 37 | key_attributes.merge(column.name => value) 38 | end 39 | end) 40 | end 41 | end 42 | 43 | unloaded_records = exploded_key_attributes.map do |key_attributes| 44 | record_set.target_class.new_empty(key_attributes, self) 45 | end 46 | 47 | super(unloaded_records) 48 | end 49 | 50 | # 51 | # Hydrate all the records in this collection from a database query 52 | # 53 | # @return [LazyRecordCollection] self 54 | def load! 55 | records_by_identity = index_by { |record| record.key_values } 56 | 57 | record_set.find_each_row do |row| 58 | identity = row.values_at(*record_set.key_column_names) 59 | records_by_identity[identity].hydrate(row) 60 | end 61 | 62 | loaded_count = count { |record| record.loaded? } 63 | if loaded_count < count 64 | fail Cequel::Record::RecordNotFound, 65 | "Expected #{count} results; got #{loaded_count}" 66 | end 67 | 68 | self 69 | end 70 | 71 | # @private 72 | def assert_fully_specified! 73 | self 74 | end 75 | 76 | private 77 | 78 | attr_reader :record_set 79 | 80 | def_delegators :record_set, :key_columns, :scoped_key_values 81 | private :key_columns, :scoped_key_values 82 | 83 | def key_attributes_for_each_row 84 | map { |record| record.key_attributes } 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/cequel/metal/writer.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # Internal representation of a data manipulation statement 6 | # 7 | # @abstract Subclasses must implement #write_to_statement, which writes 8 | # internal state to a Statement instance 9 | # 10 | # @since 1.0.0 11 | # @api private 12 | # 13 | class Writer 14 | extend Forwardable 15 | 16 | # 17 | # @param data_set [DataSet] data set to write to 18 | # 19 | def initialize(data_set, &block) 20 | @data_set, @options, @block = data_set, options, block 21 | @statements, @bind_vars = [], [] 22 | SimpleDelegator.new(self).instance_eval(&block) if block 23 | end 24 | 25 | # 26 | # Execute the statement as a write operation 27 | # 28 | # @param options [Options] options 29 | # @opiton options [Symbol] :consistency what consistency level to use for 30 | # the operation 31 | # @option options [Integer] :ttl time-to-live in seconds for the written 32 | # data 33 | # @option options [Time,Integer] :timestamp the timestamp associated with 34 | # the column values 35 | # @return [void] 36 | # 37 | def execute(options = {}) 38 | options.assert_valid_keys(:timestamp, :ttl, :consistency) 39 | return if empty? 40 | statement = Statement.new 41 | consistency = options.fetch(:consistency, data_set.query_consistency) 42 | write_to_statement(statement, options) 43 | statement.append(*data_set.row_specifications_cql) 44 | data_set.write_with_consistency( 45 | statement.cql, statement.bind_vars, consistency) 46 | end 47 | 48 | private 49 | 50 | attr_reader :data_set, :options, :statements, :bind_vars 51 | def_delegator :data_set, :table_name 52 | def_delegator :statements, :empty? 53 | 54 | def prepare_upsert_value(value) 55 | case value 56 | when ::Array 57 | yield '[?]', value 58 | when ::Set then 59 | yield '{?}', value.to_a 60 | when ::Hash then 61 | binding_pairs = ::Array.new(value.length) { '?:?' }.join(',') 62 | yield "{#{binding_pairs}}", *value.flatten 63 | else 64 | yield '?', value 65 | end 66 | end 67 | 68 | # 69 | # Generate CQL option statement for inserts and updates 70 | # 71 | def generate_upsert_options(options) 72 | upsert_options = options.slice(:timestamp, :ttl) 73 | if upsert_options.empty? 74 | '' 75 | else 76 | ' USING ' << 77 | upsert_options.map do |key, value| 78 | serialized_value = 79 | case key 80 | when :timestamp then (value.to_f * 1_000_000).to_i 81 | else value 82 | end 83 | "#{key.to_s.upcase} #{serialized_value}" 84 | end.join(' AND ') 85 | end 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/cequel/record/association_collection.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Collection of records from a 6 | # {Associations::ClassMethods#has_many has_many} association. Encapsulates 7 | # and behaves like a {RecordSet}, but unlike a normal RecordSet the loaded 8 | # records are held in memory after they are loaded. 9 | # 10 | # @see Associations::ClassMethods#has_many 11 | # @since 1.0.0 12 | # 13 | class AssociationCollection < DelegateClass(RecordSet) 14 | include Enumerable 15 | extend Forwardable 16 | 17 | # 18 | # @yield [Record] 19 | # @return [void] 20 | # 21 | def each(&block) 22 | target.each(&block) 23 | end 24 | 25 | # 26 | # (see RecordSet#find) 27 | # 28 | def find(*keys) 29 | if block_given? then super 30 | else record_set.find(*keys) 31 | end 32 | end 33 | 34 | # 35 | # (see RecordSet#select) 36 | # 37 | def select(*columns) 38 | if block_given? then super 39 | else record_set.select(*columns) 40 | end 41 | end 42 | 43 | # 44 | # (see RecordSet#first) 45 | # 46 | def first(*args) 47 | if loaded? then super 48 | else record_set.first(*args) 49 | end 50 | end 51 | 52 | # 53 | # @!method count 54 | # Get the count of child records stored in the database. This method 55 | # will always query Cassandra, even if the records are loaded in 56 | # memory. 57 | # 58 | # @return [Integer] number of child records in the database 59 | # @see #size 60 | # @see #length 61 | # 62 | def_delegator :record_set, :count 63 | 64 | # 65 | # @!method length 66 | # The number of child instances in the in-memory collection. If the 67 | # records are not loaded in memory, they will be loaded and then 68 | # counted. 69 | # 70 | # @return [Integer] length of the loaded record collection in memory 71 | # @see #size 72 | # @see #count 73 | # 74 | def_delegator :entries, :length 75 | 76 | # 77 | # Get the size of the child collection. If the records are loaded in 78 | # memory from a previous operation, count the length of the array in 79 | # memory. If the collection is unloaded, perform a `COUNT` query. 80 | # 81 | # @return [Integer] size of the child collection 82 | # @see #length 83 | # @see #count 84 | # 85 | def size 86 | loaded? ? length : count 87 | end 88 | 89 | # 90 | # @return [Boolean] true if this collection's records are loaded in 91 | # memory 92 | # 93 | def loaded? 94 | !!@target 95 | end 96 | 97 | private 98 | 99 | alias_method :record_set, :__getobj__ 100 | 101 | def target 102 | @target ||= record_set.entries 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/cequel/schema/table_writer.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Schema 4 | # 5 | # Creates a new table schema in the database 6 | # 7 | class TableWriter 8 | # 9 | # Creates a new table schema in the database given an object 10 | # representation of the schema to create 11 | # 12 | # @param (see #initialize) 13 | # @return (see #apply) 14 | # 15 | def self.apply(keyspace, table) 16 | new(keyspace, table).apply 17 | end 18 | 19 | # 20 | # @param keyspace [Keyspace] keyspace in which to create the table 21 | # @param table [Table] object representation of table schema 22 | # @private 23 | # 24 | def initialize(keyspace, table) 25 | @keyspace, @table = keyspace, table 26 | end 27 | private_class_method :new 28 | 29 | # 30 | # Create the table in the keyspace 31 | # 32 | # @return [void] 33 | # 34 | # @api private 35 | # 36 | def apply 37 | keyspace.execute(create_statement) 38 | index_statements.each { |statement| keyspace.execute(statement) } 39 | end 40 | 41 | protected 42 | 43 | attr_reader :keyspace, :table 44 | 45 | private 46 | 47 | def create_statement 48 | "CREATE TABLE #{table.name} (#{columns_cql}, #{keys_cql})".tap do |cql| 49 | properties = properties_cql 50 | cql << " WITH #{properties}" if properties 51 | end 52 | end 53 | 54 | def index_statements 55 | [].tap do |statements| 56 | table.data_columns.each do |column| 57 | if column.indexed? 58 | statements << 59 | "CREATE INDEX #{column.index_name} " \ 60 | "ON #{table.name} (#{column.name})" 61 | end 62 | end 63 | end 64 | end 65 | 66 | def columns_cql 67 | table.columns.map(&:to_cql).join(', ') 68 | end 69 | 70 | def key_columns_cql 71 | table.keys.map(&:to_cql).join(', ') 72 | end 73 | 74 | def keys_cql 75 | partition_cql = table.partition_key_columns 76 | .map { |key| key.name }.join(', ') 77 | if table.clustering_columns.any? 78 | nonpartition_cql = 79 | table.clustering_columns.map { |key| key.name }.join(', ') 80 | "PRIMARY KEY ((#{partition_cql}), #{nonpartition_cql})" 81 | else 82 | "PRIMARY KEY ((#{partition_cql}))" 83 | end 84 | end 85 | 86 | def properties_cql 87 | properties_fragments = table.properties 88 | .map { |_, property| property.to_cql } 89 | properties_fragments << 'COMPACT STORAGE' if table.compact_storage? 90 | if table.clustering_columns.any? 91 | clustering_fragment = 92 | table.clustering_columns.map(&:clustering_order_cql).join(',') 93 | properties_fragments << 94 | "CLUSTERING ORDER BY (#{clustering_fragment})" 95 | end 96 | properties_fragments.join(' AND ') if properties_fragments.any? 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/cequel/metal/batch.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require 'stringio' 3 | 4 | module Cequel 5 | module Metal 6 | # 7 | # Encapsulates a batch operation 8 | # 9 | # @see Keyspace::batch 10 | # @api private 11 | # 12 | class Batch 13 | # 14 | # @param keyspace [Keyspace] the keyspace that this batch will be 15 | # executed on 16 | # @param options [Hash] 17 | # @option options [Integer] :auto_apply If specified, flush the batch 18 | # after this many statements have been added. 19 | # @option options [Boolean] :unlogged (false) Whether to use an [unlogged 20 | # batch]( 21 | # http://www.datastax.com/dev/blog/atomic-batches-in-cassandra-1-2). 22 | # Logged batches guarantee atomicity (but not isolation) at the 23 | # cost of a performance penalty; unlogged batches are useful for bulk 24 | # write operations but behave the same as discrete writes. 25 | # @see Keyspace#batch 26 | # 27 | def initialize(keyspace, options = {}) 28 | options.assert_valid_keys(:auto_apply, :unlogged, :consistency) 29 | @keyspace = keyspace 30 | @auto_apply = options[:auto_apply] 31 | @unlogged = options.fetch(:unlogged, false) 32 | @consistency = options.fetch(:consistency, 33 | keyspace.default_consistency) 34 | reset 35 | end 36 | 37 | # 38 | # Add a statement to the batch. 39 | # 40 | # @param (see Keyspace#execute) 41 | # 42 | def execute(cql, *bind_vars) 43 | @statement.append("#{cql}\n", *bind_vars) 44 | @statement_count += 1 45 | if @auto_apply && @statement_count >= @auto_apply 46 | apply 47 | reset 48 | end 49 | end 50 | 51 | # 52 | # Send the batch to Cassandra 53 | # 54 | def apply 55 | return if @statement_count.zero? 56 | if @statement_count > 1 57 | @statement.prepend(begin_statement) 58 | @statement.append("APPLY BATCH\n") 59 | end 60 | @keyspace.execute_with_consistency( 61 | @statement.args.first, @statement.args.drop(1), @consistency) 62 | end 63 | 64 | # 65 | # Is this an unlogged batch? 66 | # 67 | # @return [Boolean] 68 | def unlogged? 69 | @unlogged 70 | end 71 | 72 | # 73 | # Is this a logged batch? 74 | # 75 | # @return [Boolean] 76 | # 77 | def logged? 78 | !unlogged? 79 | end 80 | 81 | # @private 82 | def execute_with_consistency(cql, bind_vars, query_consistency) 83 | if query_consistency && query_consistency != @consistency 84 | raise ArgumentError, 85 | "Attempting to perform query with consistency " \ 86 | "#{query_consistency.to_s.upcase} in batch with consistency " \ 87 | "#{@consistency.upcase}" 88 | end 89 | execute(cql, *bind_vars) 90 | end 91 | 92 | private 93 | 94 | def reset 95 | @statement = Statement.new 96 | @statement_count = 0 97 | end 98 | 99 | def begin_statement 100 | "BEGIN #{"UNLOGGED " if unlogged?}BATCH\n" 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'bundler/setup' 3 | require 'rspec/core/rake_task' 4 | require 'rubocop/rake_task' 5 | require 'appraisal' 6 | require File.expand_path('../lib/cequel/version', __FILE__) 7 | 8 | RUBY_VERSIONS = YAML.load_file(File.expand_path('../.travis.yml', __FILE__))['rvm'] 9 | 10 | task :default => :release 11 | task :release => [ 12 | :verify_changelog, 13 | :rubocop, 14 | :"test:all", 15 | :build, 16 | :tag, 17 | :update_stable, 18 | :push, 19 | :cleanup 20 | ] 21 | 22 | desc 'Build gem' 23 | task :build do 24 | system 'gem build cequel.gemspec' 25 | end 26 | 27 | desc 'Create git release tag' 28 | task :tag do 29 | system "git tag -a -m 'Version #{Cequel::VERSION}' #{Cequel::VERSION}" 30 | system "git push git@github.com:cequel/cequel.git #{Cequel::VERSION}:#{Cequel::VERSION}" 31 | end 32 | 33 | desc 'Update stable branch on GitHub' 34 | task :update_stable do 35 | if Cequel::VERSION =~ /^(\d+\.)+\d+$/ # Don't push for prerelease 36 | system "git push -f origin HEAD:stable" 37 | end 38 | end 39 | 40 | desc 'Push gem to repository' 41 | task :push do 42 | system "gem push cequel-#{Cequel::VERSION}.gem" 43 | end 44 | 45 | task 'Remove packaged gems' 46 | task :cleanup do 47 | system "rm -v *.gem" 48 | end 49 | 50 | desc 'Run the specs' 51 | RSpec::Core::RakeTask.new(:test) do |t| 52 | t.pattern = './spec/examples/**/*_spec.rb' 53 | t.rspec_opts = '-b' 54 | end 55 | 56 | namespace :bundle do 57 | desc 'Run bundler for all environments' 58 | task :all do 59 | abort unless all_rubies('bundle') 60 | abort unless all_rubies('rake', 'appraisal:install') 61 | end 62 | 63 | desc 'Update to latest dependencies on all environments' 64 | task :update_all do 65 | gemfiles = File.expand_path("../gemfiles", __FILE__) 66 | FileUtils.rm_r(gemfiles, :verbose => true) if File.exist?(gemfiles) 67 | abort unless system('bundle', 'update') 68 | abort unless all_rubies('bundle') 69 | abort unless all_rubies('rake', 'appraisal:install') 70 | end 71 | end 72 | 73 | desc 'Check style with Rubocop' 74 | Rubocop::RakeTask.new(:rubocop) do |task| 75 | task.patterns = ['lib/**/*.rb'] 76 | task.formatters = ['files'] 77 | task.fail_on_error = true 78 | end 79 | 80 | namespace :test do 81 | desc 'Run the specs with progress formatter' 82 | RSpec::Core::RakeTask.new(:concise) do |t| 83 | t.pattern = './spec/examples/**/*_spec.rb' 84 | t.rspec_opts = '--fail-fast --format=progress' 85 | t.fail_on_error = true 86 | end 87 | end 88 | 89 | namespace :test do 90 | task :all do 91 | abort unless all_rubies('rake', 'appraisal', 'test:concise') 92 | end 93 | end 94 | 95 | desc 'Update changelog' 96 | task :changelog do 97 | require './lib/cequel/version.rb' 98 | 99 | last_tag = `git tag`.each_line.map(&:strip).last 100 | existing_changelog = File.read('./CHANGELOG.md') 101 | File.open('./CHANGELOG.md', 'w') do |f| 102 | f.puts "## #{Cequel::VERSION}" 103 | f.puts "" 104 | f.puts `git log --no-merges --pretty=format:'* %s' #{last_tag}..` 105 | f.puts "" 106 | f.puts existing_changelog 107 | end 108 | end 109 | 110 | task :verify_changelog do 111 | require './lib/cequel/version.rb' 112 | 113 | if File.read('./CHANGELOG.md').each_line.first.strip != "## #{Cequel::VERSION}" 114 | abort "Changelog is not up-to-date." 115 | end 116 | end 117 | 118 | def all_rubies(*command) 119 | !RUBY_VERSIONS.find do |version| 120 | !system('rvm', version, 'do', *command) 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/examples/record/validations_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require_relative 'spec_helper' 3 | 4 | describe Cequel::Record::Validations do 5 | model :Post do 6 | key :permalink, :text 7 | column :title, :text 8 | column :body, :text 9 | 10 | validates :title, :presence => true 11 | before_validation { |post| post.called_validate_callback = true } 12 | 13 | attr_accessor :called_validate_callback 14 | end 15 | 16 | let(:invalid_post) do 17 | Post.new do |post| 18 | post.permalink = 'invalid' 19 | post.body = 'This is an invalid post.' 20 | end 21 | end 22 | let(:valid_post) do 23 | Post.new do |post| 24 | post.permalink = 'valid' 25 | post.title = 'Valid Post' 26 | end 27 | end 28 | let(:unloaded_post) { Post['unloaded'] } 29 | 30 | describe '#valid?' do 31 | it 'should be false if model is not valid' do 32 | invalid_post.should_not be_valid 33 | end 34 | 35 | it 'should be true if model is valid' do 36 | valid_post.should be_valid 37 | end 38 | end 39 | 40 | describe '#invalid?' do 41 | it 'should be true if model is not valid' do 42 | invalid_post.should be_invalid 43 | end 44 | 45 | it 'should be false if model is valid' do 46 | valid_post.should_not be_invalid 47 | end 48 | end 49 | 50 | describe '#save' do 51 | it 'should return false and not persist model if invalid' do 52 | invalid_post.save.should be_false 53 | end 54 | 55 | it 'should return true and persist model if valid' do 56 | valid_post.save.should be_true 57 | Post.find('valid').title.should == 'Valid Post' 58 | end 59 | 60 | it 'should bypass validations if :validate => false is passed' do 61 | invalid_post.save(:validate => false).should be_true 62 | Post.find('invalid').body.should == 'This is an invalid post.' 63 | end 64 | end 65 | 66 | describe '#save!' do 67 | it 'should raise error and not persist model if invalid' do 68 | expect { invalid_post.save! }. 69 | to raise_error(Cequel::Record::RecordInvalid) 70 | end 71 | 72 | it 'should persist model and return self if valid' do 73 | expect { valid_post.save! }.to_not raise_error 74 | Post.find(valid_post.permalink).title.should == 'Valid Post' 75 | end 76 | end 77 | 78 | describe '#update_attributes!' do 79 | it 'should raise error and not update data in the database' do 80 | expect { invalid_post.update_attributes!(:body => 'My Post') }. 81 | to raise_error(Cequel::Record::RecordInvalid) 82 | end 83 | 84 | it 'should return successfully and update data in the database if valid' do 85 | invalid_post.update_attributes!(:title => 'My Post') 86 | Post.find(invalid_post.permalink).title.should == 'My Post' 87 | end 88 | end 89 | 90 | describe '::create!' do 91 | it 'should raise RecordInvalid and not persist model if invalid' do 92 | expect do 93 | Post.create!(:permalink => 'cequel', :body => 'Cequel') 94 | end.to raise_error(Cequel::Record::RecordInvalid) 95 | end 96 | 97 | it 'should persist record to database if valid' do 98 | Post.create!(:permalink => 'cequel', :title => 'Cequel') 99 | Post.find('cequel').title.should == 'Cequel' 100 | end 101 | end 102 | 103 | describe 'callbacks' do 104 | it 'should call validation callbacks' do 105 | post = Post.new(:title => 'cequel') 106 | post.valid? 107 | post.called_validate_callback.should be_true 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/cequel/schema/table_property.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Schema 4 | # 5 | # Encapsulates a CQL3 storage property defined on a table 6 | # 7 | class TableProperty 8 | # @return [Symbol] name of the property 9 | attr_reader :name 10 | # @return value of the property 11 | attr_reader :value 12 | 13 | # 14 | # Initialize an instance of the appropriate TableProperty implementation. 15 | # 16 | # @param (see #initialize) 17 | # @api private 18 | # 19 | def self.build(name, value) 20 | clazz = 21 | case name.to_sym 22 | when :compaction then CompactionProperty 23 | when :compression then CompressionProperty 24 | else TableProperty 25 | end 26 | clazz.new(name, value) 27 | end 28 | 29 | # 30 | # @param name [Symbol] name of the property 31 | # @param value value of the property 32 | # 33 | def initialize(name, value) 34 | @name = name 35 | self.normalized_value = value 36 | end 37 | class << self; protected :new; end 38 | 39 | # 40 | # @return [String] CQL fragment defining this property in a `CREATE 41 | # TABLE` statement 42 | # 43 | def to_cql 44 | "#{@name} = #{value_cql}" 45 | end 46 | 47 | protected 48 | 49 | def normalized_value=(value) 50 | @value = value 51 | end 52 | 53 | private 54 | 55 | def value_cql 56 | quote(@value) 57 | end 58 | 59 | def quote(value) 60 | Cequel::Type.quote(value) 61 | end 62 | end 63 | 64 | # 65 | # A table property whose value is itself a map of keys and values 66 | # 67 | # @abstract Inheriting classes must implement 68 | # `#normalize_map_property(key, value)` 69 | # 70 | class MapProperty < TableProperty 71 | protected 72 | 73 | def normalized_value=(map) 74 | @value = {} 75 | map.each_pair do |key, value| 76 | key = key.to_sym 77 | @value[key] = normalize_map_property(key, value) 78 | end 79 | end 80 | 81 | private 82 | 83 | def value_cql 84 | map_pairs = @value.each_pair 85 | .map { |key, value| "#{quote(key.to_s)} : #{quote(value)}" } 86 | .join(', ') 87 | "{ #{map_pairs} }" 88 | end 89 | end 90 | 91 | # 92 | # A property comprising key-value pairs of compaction settings 93 | # 94 | class CompactionProperty < MapProperty 95 | private 96 | 97 | def normalize_map_property(key, value) 98 | case key 99 | when :class 100 | value.sub(/^org\.apache\.cassandra\.db\.compaction\./, '') 101 | when :bucket_high, :bucket_low, :tombstone_threshold then value.to_f 102 | when :max_threshold, :min_threshold, :min_sstable_size, 103 | :sstable_size_in_mb, :tombstone_compaction_interval then value.to_i 104 | else value.to_s 105 | end 106 | end 107 | end 108 | 109 | # 110 | # A property comprising key-value pairs of compression settings 111 | # 112 | class CompressionProperty < MapProperty 113 | private 114 | 115 | def normalize_map_property(key, value) 116 | case key 117 | when :sstable_compression 118 | value.sub(/^org\.apache\.cassandra\.io\.compress\./, '') 119 | when :chunk_length_kb then value.to_i 120 | when :crc_check_chance then value.to_f 121 | else value.to_s 122 | end 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/examples/record/callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require File.expand_path('../spec_helper', __FILE__) 3 | 4 | describe Cequel::Record::Callbacks do 5 | model :Post do 6 | key :permalink, :text 7 | column :title, :text 8 | 9 | def self.track_callbacks(*events) 10 | events.each do |event| 11 | %w(before after).each do |position| 12 | callback_name = :"#{position}_#{event}" 13 | __send__(callback_name) do |post| 14 | post.executed_callbacks << callback_name 15 | end 16 | end 17 | end 18 | end 19 | 20 | track_callbacks :save, :create, :update, :destroy 21 | 22 | def executed_callbacks 23 | @executed_callbacks ||= [] 24 | end 25 | 26 | end 27 | 28 | model :Comment do 29 | belongs_to :post 30 | key :id, :timeuuid, :auto => true 31 | column :body, :text 32 | 33 | before_save :create_post 34 | after_save :run_instance_after_save 35 | 36 | attr_writer :instance_after_save 37 | 38 | private 39 | 40 | def create_post 41 | post = Post.create!(permalink: 'autopost', title: 'Auto Post') 42 | self.post = post 43 | end 44 | 45 | def run_instance_after_save 46 | @instance_after_save.call 47 | end 48 | end 49 | 50 | let(:new_post) do 51 | Post.new do |post| 52 | post.permalink = 'new-post' 53 | post.title = 'New Post' 54 | end 55 | end 56 | 57 | let!(:existing_post) do 58 | Post.new do |post| 59 | post.permalink = 'existing-post' 60 | post.title = 'Existing Post' 61 | end.save! 62 | Post.find('existing-post').tap do |post| 63 | post.title = 'An Existing Post' 64 | end 65 | end 66 | 67 | context 'on create' do 68 | before { new_post.save! } 69 | subject { new_post.executed_callbacks } 70 | 71 | it { should include(:before_save) } 72 | it { should include(:after_save) } 73 | it { should include(:before_create) } 74 | it { should include(:after_create) } 75 | it { should_not include(:before_update) } 76 | it { should_not include(:after_update) } 77 | it { should_not include(:before_destroy) } 78 | it { should_not include(:after_destroy) } 79 | end 80 | 81 | context 'on update' do 82 | before { existing_post.save! } 83 | subject { existing_post.executed_callbacks } 84 | 85 | it { should include(:before_save) } 86 | it { should include(:after_save) } 87 | it { should_not include(:before_create) } 88 | it { should_not include(:after_create) } 89 | it { should include(:before_update) } 90 | it { should include(:after_update) } 91 | it { should_not include(:before_destroy) } 92 | it { should_not include(:after_destroy) } 93 | end 94 | 95 | context 'on destroy' do 96 | before { existing_post.destroy } 97 | 98 | subject { existing_post.executed_callbacks } 99 | 100 | it { should_not include(:before_save) } 101 | it { should_not include(:after_save) } 102 | it { should_not include(:before_create) } 103 | it { should_not include(:after_create) } 104 | it { should_not include(:before_update) } 105 | it { should_not include(:after_update) } 106 | it { should include(:before_destroy) } 107 | it { should include(:after_destroy) } 108 | end 109 | 110 | describe 'atomic writes' do 111 | it 'should run callbacks in a logged batch' do 112 | comment = Comment.new(:body => 'Great web site!') 113 | comment.instance_after_save = 114 | -> { expect { Post.find('autopost') }. 115 | to raise_error(Cequel::Record::RecordNotFound) } 116 | comment.save! 117 | Post.find('autopost').title.should == 'Auto Post' 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | 4 | module SpecSupport 5 | module Macros 6 | def model(class_name, options = {}, &block) 7 | return if RSpec.configuration.filter_manager.exclude?(self) 8 | setup_models = !self.metadata.key?(:models) 9 | self.metadata[:models] ||= {} 10 | 11 | metadata[:models][class_name] = [options, block] 12 | 13 | if setup_models 14 | before :all do 15 | metadata = self.class.metadata 16 | metadata[:models].each do |name, (options, block)| 17 | clazz = Class.new do 18 | include Cequel::Record 19 | self.table_name = name.to_s.tableize 20 | class_eval(&block) 21 | end 22 | Object.module_eval { const_set(name, clazz) } 23 | end 24 | metadata[:models].each_key do |name| 25 | if options.fetch(:synchronize_schema, true) 26 | Object.const_get(name).synchronize_schema 27 | end 28 | end 29 | end 30 | 31 | before :each do 32 | metadata = self.class.metadata 33 | metadata[:models].each_key do |name| 34 | name.to_s.constantize.find_each(&:destroy) 35 | end 36 | end 37 | 38 | after :all do 39 | self.class.metadata[:models].each_key do |name| 40 | cequel.schema.drop_table(Object.const_get(name).table_name) 41 | Object.module_eval { remove_const(name) } 42 | end 43 | end 44 | end 45 | end 46 | 47 | def uuid(name) 48 | let(name) { Cequel.uuid } 49 | end 50 | end 51 | 52 | module Helpers 53 | 54 | def self.cequel 55 | @cequel ||= Cequel.connect( 56 | host: host, 57 | port: port, 58 | keyspace: keyspace_name 59 | ).tap do |cequel| 60 | if ENV['CEQUEL_LOG_QUERIES'] 61 | cequel.logger = Logger.new(STDOUT) 62 | else 63 | cequel.logger = Logger.new(File.open('/dev/null', 'a')) 64 | end 65 | end 66 | end 67 | 68 | def self.host 69 | '127.0.0.1' 70 | end 71 | 72 | def self.port 73 | ENV['CEQUEL_TEST_PORT'] || '9042' 74 | end 75 | 76 | def self.legacy_host 77 | ENV['CEQUEL_TEST_LEGACY_HOST'] || '127.0.0.1:9160' 78 | end 79 | 80 | def self.keyspace_name 81 | ENV['CEQUEL_TEST_KEYSPACE'] || 'cequel_test' 82 | end 83 | 84 | def self.legacy_connection 85 | require 'cassandra-cql' 86 | @legacy_connection ||= CassandraCQL::Database.new( 87 | legacy_host, 88 | :keyspace => keyspace_name, 89 | :cql_version => '2.0.0' 90 | ) 91 | end 92 | 93 | def min_uuid(time = Time.now) 94 | Cql::TimeUuid::Generator.new(0, 0).from_time(time, 0) 95 | end 96 | 97 | def max_uuid(time = Time.now) 98 | Cql::TimeUuid::Generator.new(0x3fff, 0xffffffffffff). 99 | from_time(time, 999) 100 | end 101 | 102 | def cequel 103 | Helpers.cequel 104 | end 105 | 106 | def legacy_connection 107 | Helpers.legacy_connection 108 | end 109 | 110 | def max_statements!(number) 111 | cequel.client.should_receive(:execute).at_most(number).times.and_call_original 112 | end 113 | 114 | def disallow_queries! 115 | cequel.client.should_not_receive(:execute) 116 | end 117 | 118 | def expect_query_with_consistency(matcher, consistency) 119 | expect(cequel.client).to receive(:execute).with(matcher, consistency) 120 | .and_call_original 121 | yield 122 | RSpec::Mocks.proxy_for(cequel.client).reset 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/cequel/record/finders.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # Cequel provides finder methods to construct scopes for looking up records 6 | # by primary key or secondary indexed columns. 7 | # 8 | # @example Example model class 9 | # class Post 10 | # key :blog_subdomain, :text 11 | # key :id, :timeuuid, auto: true 12 | # column :title, :text 13 | # column :body, :text 14 | # column :author_id, :uuid, index: true # this column has a secondary 15 | # # index 16 | # end 17 | # 18 | # @example Using some but not all primary key columns 19 | # # return an Array of all posts with given subdomain (greedy load) 20 | # Post.find_all_by_blog_subdomain(subdomain) 21 | # 22 | # # return a {RecordSet} of all posts with the given subdomain (lazy 23 | # # load) 24 | # Post.with_subdomain(subdomain) 25 | # 26 | # @example Using all primary key columns 27 | # # return the first post with the given subdomain and id, or nil if none 28 | # Post.find_by_blog_subdomain_and_id(subdomain, id) 29 | # 30 | # # return a record set to the post with the given subdomain and id 31 | # # (one element array if exists, empty array otherwise) 32 | # Post.with_blog_subdomain_and_id(subdomain, id) 33 | # 34 | # @example Chaining 35 | # # return the first post with the given subdomain and id, or nil if none 36 | # # Note that find_by_id can only be called on a scope that already has a 37 | # # filter value for blog_subdomain 38 | # Post.with_blog_subdomain(subdomain).find_by_id(id) 39 | # 40 | # @example Using a secondary index 41 | # # return the first record with the author_id 42 | # Post.find_by_author_id(id) 43 | # 44 | # # return an Array of all records with the author_id 45 | # Post.find_all_by_author_id(id) 46 | # 47 | # # return a RecordSet scoped to the author_id 48 | # Post.with_author_id(id) 49 | # 50 | # @since 1.2.0 51 | # 52 | module Finders 53 | private 54 | 55 | def key(*) 56 | if key_columns.any? 57 | def_key_finders('find_all_by', '.entries') 58 | undef_key_finders('find_by') 59 | end 60 | super 61 | def_key_finders('find_by', '.first') 62 | def_key_finders('with') 63 | end 64 | 65 | def column(name, type, options = {}) 66 | super 67 | if options[:index] 68 | def_finder('with', [name]) 69 | def_finder('find_by', [name], '.first') 70 | def_finder('find_all_by', [name], '.entries') 71 | end 72 | end 73 | 74 | def def_key_finders(method_prefix, scope_operation = '') 75 | def_finder(method_prefix, key_column_names, scope_operation) 76 | def_finder(method_prefix, key_column_names.last(1), scope_operation) 77 | end 78 | 79 | def def_finder(method_prefix, column_names, scope_operation = '') 80 | arg_names = column_names.join(', ') 81 | method_suffix = finder_method_suffix(column_names) 82 | column_filter_expr = column_names 83 | .map { |name| "#{name}: #{name}" }.join(', ') 84 | 85 | singleton_class.module_eval(<<-RUBY, __FILE__, __LINE__+1) 86 | def #{method_prefix}_#{method_suffix}(#{arg_names}) 87 | where(#{column_filter_expr})#{scope_operation} 88 | end 89 | RUBY 90 | end 91 | 92 | def undef_key_finders(method_prefix) 93 | method_suffix = finder_method_suffix(key_column_names) 94 | method_name = "#{method_prefix}_#{method_suffix}" 95 | singleton_class.module_eval { undef_method(method_name) } 96 | end 97 | 98 | def finder_method_suffix(column_names) 99 | column_names.join('_and_') 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/cequel/record.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require 'active_model' 3 | 4 | require 'cequel' 5 | require 'cequel/record/errors' 6 | require 'cequel/record/schema' 7 | require 'cequel/record/properties' 8 | require 'cequel/record/collection' 9 | require 'cequel/record/persistence' 10 | require 'cequel/record/bulk_writes' 11 | require 'cequel/record/record_set' 12 | require 'cequel/record/data_set_builder' 13 | require 'cequel/record/bound' 14 | require 'cequel/record/lazy_record_collection' 15 | require 'cequel/record/scoped' 16 | require 'cequel/record/finders' 17 | require 'cequel/record/associations' 18 | require 'cequel/record/association_collection' 19 | require 'cequel/record/belongs_to_association' 20 | require 'cequel/record/has_many_association' 21 | require 'cequel/record/mass_assignment' 22 | require 'cequel/record/callbacks' 23 | require 'cequel/record/validations' 24 | require 'cequel/record/dirty' 25 | require 'cequel/record/conversion' 26 | 27 | require 'cequel/record' 28 | 29 | if defined? Rails 30 | require 'cequel/record/railtie' 31 | end 32 | 33 | module Cequel 34 | # 35 | # Cequel::Record is an active record-style data modeling library and 36 | # object-row mapper. Model classes inherit from Cequel::Record, define their 37 | # columns in the class definition, and have access to a full and robust set 38 | # of read and write functionality. 39 | # 40 | # Individual components are documented in their respective modules. See below 41 | # for links. 42 | # 43 | # @example A Record class showing off many of the possibilities 44 | # class Post 45 | # include Cequel::Record 46 | # 47 | # belongs_to :blog 48 | # key :id, :timeuuid, auto: true 49 | # column :title, :text 50 | # column :body, :text 51 | # column :author_id, :uuid, index: true 52 | # set :categories 53 | # 54 | # has_many :comments, dependent: destroy 55 | # 56 | # after_create :notify_followers 57 | # 58 | # validates :title, presence: true 59 | # 60 | # def self.for_author(author_id) 61 | # where(:author_id, author_id) 62 | # end 63 | # end 64 | # 65 | # @see Properties Defining properties 66 | # @see Collection Collection columns 67 | # @see Associations Defining associations between records 68 | # @see Persistence Creating, updating, and destroying records 69 | # @see BulkWrites Updating and destroying records in bulk 70 | # @see RecordSet Loading records from the database 71 | # @see Finders Magic finder methods 72 | # @see MassAssignment Mass-assignment protection and strong attributes 73 | # @see Callbacks Lifecycle hooks 74 | # @see Validations 75 | # @see Dirty Dirty attribute tracking 76 | # 77 | module Record 78 | extend ActiveSupport::Concern 79 | extend Forwardable 80 | 81 | included do 82 | include Properties 83 | include Schema 84 | include Persistence 85 | include Associations 86 | include Scoped 87 | extend Finders 88 | include MassAssignment 89 | include Callbacks 90 | include Validations 91 | include Dirty 92 | extend ActiveModel::Naming 93 | include Conversion 94 | include ActiveModel::Serializers::JSON 95 | include ActiveModel::Serializers::Xml 96 | end 97 | 98 | # 99 | # Empty, but third-party libraries can add class-level functionality here 100 | # 101 | module ClassMethods 102 | end 103 | 104 | class < 'Test Comment' 113 | end 114 | end 115 | 116 | it 'should change properties' do 117 | table.properties[:comment].value.should == 'Test Comment' 118 | end 119 | end 120 | 121 | describe '#add_index' do 122 | before do 123 | cequel.schema.alter_table(:posts) do 124 | create_index :title 125 | end 126 | end 127 | 128 | it 'should add the index' do 129 | table.data_column(:title).should be_indexed 130 | end 131 | end 132 | 133 | describe '#drop_index' do 134 | before do 135 | cequel.schema.alter_table(:posts) do 136 | create_index :title 137 | drop_index :posts_title_idx 138 | end 139 | end 140 | 141 | it 'should drop the index' do 142 | table.data_column(:title).should_not be_indexed 143 | end 144 | end 145 | 146 | describe '#drop_column' do 147 | before do 148 | pending 'Support in a future Cassandra version' 149 | cequel.schema.alter_table(:posts) do 150 | drop_column :body 151 | end 152 | end 153 | 154 | it 'should remove the column' do 155 | table.data_column(:body).should be_nil 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/cequel/schema/migration_validator.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Schema 4 | # 5 | # This is a utility class to test that it is possible to perform a given 6 | # table schema migration 7 | # 8 | # @api private 9 | # 10 | class MigrationValidator 11 | extend Forwardable 12 | # 13 | # Check for various impossible schema changes and raise if any are found 14 | # 15 | # @param (see #initialize) 16 | # @return [void] 17 | # @raise (see #validate) 18 | # 19 | def self.validate!(synchronizer) 20 | new(synchronizer).validate! 21 | end 22 | 23 | # 24 | # @param synchronizer [TableSynchronizer] the synchronizer to validate 25 | # 26 | def initialize(synchronizer) 27 | @synchronizer = synchronizer 28 | end 29 | 30 | # 31 | # Check for various impossible schema changes and raise if any are found 32 | # 33 | # @raise [InvalidSchemaMigration] if it is impossible to modify existing 34 | # table to match desired schema 35 | # 36 | def validate! 37 | assert_keys_match! 38 | assert_data_columns_match! 39 | end 40 | 41 | private 42 | 43 | attr_reader :synchronizer 44 | def_delegators :synchronizer, :each_key_pair, 45 | :each_clustering_column_pair, :each_data_column_pair, 46 | :existing, :updated 47 | 48 | def assert_keys_match! 49 | assert_partition_keys_match! 50 | assert_clustering_columns_match! 51 | assert_same_key_types! 52 | assert_same_clustering_order! 53 | end 54 | 55 | def assert_same_key_types! 56 | each_key_pair do |old_key, new_key| 57 | if old_key.type != new_key.type 58 | fail InvalidSchemaMigration, 59 | "Can't change type of key column #{old_key.name} from " \ 60 | "#{old_key.type} to #{new_key.type}" 61 | end 62 | end 63 | end 64 | 65 | def assert_same_clustering_order! 66 | each_clustering_column_pair do |old_key, new_key| 67 | if old_key.clustering_order != new_key.clustering_order 68 | fail InvalidSchemaMigration, 69 | "Can't change the clustering order of #{old_key.name} from " \ 70 | "#{old_key.clustering_order} to #{new_key.clustering_order}" 71 | end 72 | end 73 | end 74 | 75 | def assert_partition_keys_match! 76 | if existing.partition_key_column_count != 77 | updated.partition_key_column_count 78 | 79 | fail InvalidSchemaMigration, 80 | "Existing partition keys " \ 81 | "#{existing.partition_key_column_names.join(',')} " \ 82 | "differ from specified partition keys " \ 83 | "#{updated.partition_key_column_names.join(',')}" 84 | end 85 | end 86 | 87 | def assert_clustering_columns_match! 88 | if existing.clustering_column_count != updated.clustering_column_count 89 | fail InvalidSchemaMigration, 90 | "Existing clustering columns " \ 91 | "#{existing.clustering_column_names.join(',')} " \ 92 | "differ from specified clustering keys " \ 93 | "#{updated.clustering_column_names.join(',')}" 94 | end 95 | end 96 | 97 | def assert_data_columns_match! 98 | each_data_column_pair do |old_column, new_column| 99 | if old_column && new_column 100 | assert_valid_type_transition!(old_column, new_column) 101 | assert_same_column_structure!(old_column, new_column) 102 | end 103 | end 104 | end 105 | 106 | def assert_valid_type_transition!(old_column, new_column) 107 | if old_column.type != new_column.type 108 | valid_new_types = old_column.type.compatible_types 109 | unless valid_new_types.include?(new_column.type) 110 | fail InvalidSchemaMigration, 111 | "Can't change #{old_column.name} from " \ 112 | "#{old_column.type} to #{new_column.type}. " \ 113 | "#{old_column.type} columns may only be altered to " \ 114 | "#{valid_new_types.to_sentence}." 115 | end 116 | end 117 | end 118 | 119 | def assert_same_column_structure!(old_column, new_column) 120 | if old_column.class != new_column.class 121 | fail InvalidSchemaMigration, 122 | "Can't change #{old_column.name} from " \ 123 | "#{old_column.class.name.demodulize} to " \ 124 | "#{new_column.class.name.demodulize}" 125 | end 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/cequel/metal/updater.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Metal 4 | # 5 | # Builder for `UPDATE` statement containing heterogeneous operations (set 6 | # columns, atomically mutate collections) 7 | # 8 | # @see DataSet#update 9 | # @see Deleter 10 | # @see 11 | # http://cassandra.apache.org/doc/cql3/CQL.html#updateStmt 12 | # CQL UPDATE documentation 13 | # @since 1.0.0 14 | # 15 | class Updater < Writer 16 | # 17 | # @see Writer#initialize 18 | # 19 | def initialize(*) 20 | @column_updates = {} 21 | super 22 | end 23 | 24 | # 25 | # Directly set column values 26 | # 27 | # @param data [Hash] map of column names to values 28 | # @return [void] 29 | # 30 | # @see DataSet#update 31 | # 32 | def set(data) 33 | data.each_pair do |column, value| 34 | column_updates[column.to_sym] = value 35 | end 36 | end 37 | 38 | # 39 | # Prepend elements to a list column 40 | # 41 | # @param column [Symbol] column name 42 | # @param elements [Array] elements to prepend 43 | # @return [void] 44 | # 45 | # @see DataSet#list_prepend 46 | # 47 | def list_prepend(column, elements) 48 | statements << "#{column} = [?] + #{column}" 49 | bind_vars << elements 50 | end 51 | 52 | # 53 | # Append elements to a list column 54 | # 55 | # @param column [Symbol] column name 56 | # @param elements [Array] elements to append 57 | # @return [void] 58 | # 59 | # @see DataSet#list_append 60 | # 61 | def list_append(column, elements) 62 | statements << "#{column} = #{column} + [?]" 63 | bind_vars << elements 64 | end 65 | 66 | # 67 | # Remove all occurrences of an element from a list 68 | # 69 | # @param column [Symbol] column name 70 | # @param value value to remove 71 | # @return [void] 72 | # 73 | # @see DataSet#list_remove 74 | # 75 | def list_remove(column, value) 76 | statements << "#{column} = #{column} - [?]" 77 | bind_vars << value 78 | end 79 | 80 | # 81 | # Replace a list item at a given position 82 | # 83 | # @param column [Symbol] column name 84 | # @param index [Integer] index at which to replace value 85 | # @param value new value for position 86 | # @return [void] 87 | # 88 | # @see DataSet#list_replace 89 | # 90 | def list_replace(column, index, value) 91 | statements << "#{column}[#{index}] = ?" 92 | bind_vars << value 93 | end 94 | 95 | # 96 | # Add elements to a set 97 | # 98 | # @param column [Symbol] column name 99 | # @param values [Set] elements to add to set 100 | # @return [void] 101 | # 102 | # @see DataSet#set_add 103 | # 104 | def set_add(column, values) 105 | statements << "#{column} = #{column} + {?}" 106 | bind_vars << values 107 | end 108 | 109 | # 110 | # Remove elements from a set 111 | # 112 | # @param column [Symbol] column name 113 | # @param values [Set] elements to remove from set 114 | # @return [void] 115 | # 116 | # @see DataSet#set_remove 117 | # 118 | def set_remove(column, values) 119 | statements << "#{column} = #{column} - {?}" 120 | bind_vars << ::Kernel.Array(values) 121 | end 122 | 123 | # 124 | # Add or update elements in a map 125 | # 126 | # @param column [Symbol] column name 127 | # @param updates [Hash] map of keys to values to update in map 128 | # @return [void] 129 | # 130 | # @see DataSet#map_update 131 | # 132 | def map_update(column, updates) 133 | binding_pairs = ::Array.new(updates.length) { '?:?' }.join(',') 134 | statements << "#{column} = #{column} + {#{binding_pairs}}" 135 | bind_vars.concat(updates.flatten) 136 | end 137 | 138 | private 139 | 140 | attr_reader :column_updates 141 | 142 | def empty? 143 | super && column_updates.empty? 144 | end 145 | 146 | def write_to_statement(statement, options) 147 | prepare_column_updates 148 | statement.append("UPDATE #{table_name}") 149 | .append(generate_upsert_options(options)) 150 | .append(" SET ") 151 | .append(statements.join(', '), *bind_vars) 152 | end 153 | 154 | def prepare_column_updates 155 | column_updates.each_pair do |column, value| 156 | prepare_upsert_value(value) do |binding, *values| 157 | statements << "#{column} = #{binding}" 158 | bind_vars.concat(values) 159 | end 160 | end 161 | end 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/cequel/record/schema.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Record 4 | # 5 | # `Cequel::Record` implementations define their own schema in their class 6 | # definitions. As well as defining attributes on record instances, the 7 | # column definitions in {Properties} allow a `Cequel::Record` to have a 8 | # precise internal represntation of its representation as a CQL3 table 9 | # schema. Further, it is able to check this representation against the 10 | # actual table defined in Cassandra (if any), and create or modify the 11 | # schema in Cassandra to match what's defined in code. 12 | # 13 | # All the interesting stuff is in the {ClassMethods}. 14 | # 15 | # @since 1.0.0 16 | # 17 | module Schema 18 | extend ActiveSupport::Concern 19 | extend Forwardable 20 | 21 | included do 22 | class_attribute :table_name, instance_writer: false 23 | self.table_name = name.tableize.to_sym unless name.nil? 24 | end 25 | 26 | # 27 | # Methods available on {Record} class singletons to introspect and modify 28 | # the schema defined in the database 29 | # 30 | module ClassMethods 31 | # 32 | # @!attr table_name 33 | # @return [Symbol] name of the CQL table that backs this record class 34 | # 35 | 36 | extend Forwardable 37 | 38 | # 39 | # @!attribute [r] columns 40 | # (see Cequel::Schema::Table#columns) 41 | # 42 | # @!attribute [r] key_columns 43 | # (see Cequel::Schema::Table#key_columns) 44 | # 45 | # @!attribute [r] key_column_names 46 | # (see Cequel::Schema::Table#key_column_names) 47 | # 48 | # @!attribute [r] partition_key_columns 49 | # (see Cequel::Schema::Table#partition_key_columns) 50 | # 51 | # @!attribute [r] partition_key_column_names 52 | # (see Cequel::Schema::Table#partition_key_column_names) 53 | # 54 | # @!attribute [r] clustering_columns 55 | # (see Cequel::Schema::Table#clustering_columns) 56 | # 57 | # @!method compact_storage? 58 | # (see Cequel::Schema::Table#compact_storage?) 59 | # 60 | def_delegators :table_schema, :columns, :key_columns, 61 | :key_column_names, :partition_key_columns, 62 | :partition_key_column_names, :clustering_columns, 63 | :compact_storage? 64 | # 65 | # @!method reflect_on_column(name) 66 | # (see Cequel::Schema::Table#column) 67 | # 68 | def_delegator :table_schema, :column, :reflect_on_column 69 | 70 | # 71 | # Read the current schema assigned to this record's table from 72 | # Cassandra, and make any necessary modifications (including creating 73 | # the table for the first time) so that it matches the schema defined 74 | # in the record definition 75 | # 76 | # @raise (see Schema::TableSynchronizer.apply) 77 | # @return [void] 78 | # 79 | def synchronize_schema 80 | Cequel::Schema::TableSynchronizer 81 | .apply(connection, read_schema, table_schema) 82 | end 83 | 84 | # 85 | # Read the current state of this record's table in Cassandra from the 86 | # database. 87 | # 88 | # @return [Schema::Table] the current schema assigned to this record's 89 | # table in the database 90 | # 91 | def read_schema 92 | connection.schema.read_table(table_name) 93 | end 94 | 95 | # 96 | # @return [Schema::Table] the schema as defined by the columns 97 | # specified in the class definition 98 | # 99 | def table_schema 100 | @table_schema ||= Cequel::Schema::Table.new(table_name) 101 | end 102 | 103 | protected 104 | 105 | def key(name, type, options = {}) 106 | super 107 | if options[:partition] 108 | table_schema.add_partition_key(name, type) 109 | else 110 | table_schema.add_key(name, type, options[:order]) 111 | end 112 | end 113 | 114 | def column(name, type, options = {}) 115 | super 116 | table_schema.add_data_column(name, type, options[:index]) 117 | end 118 | 119 | def list(name, type, options = {}) 120 | super 121 | table_schema.add_list(name, type) 122 | end 123 | 124 | def set(name, type, options = {}) 125 | super 126 | table_schema.add_set(name, type) 127 | end 128 | 129 | def map(name, key_type, value_type, options = {}) 130 | super 131 | table_schema.add_map(name, key_type, value_type) 132 | end 133 | 134 | def table_property(name, value) 135 | table_schema.add_property(name, value) 136 | end 137 | 138 | def compact_storage 139 | table_schema.compact_storage = true 140 | end 141 | end 142 | 143 | protected 144 | 145 | def_delegator 'self.class', :table_schema 146 | protected :table_schema 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /spec/examples/record/set_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require File.expand_path('../spec_helper', __FILE__) 3 | 4 | describe Cequel::Record::Set do 5 | model :Post do 6 | key :permalink, :text 7 | column :title, :text 8 | set :tags, :text 9 | end 10 | 11 | let(:scope) { cequel[:posts].where(:permalink => 'cequel') } 12 | subject { scope.first } 13 | 14 | let! :post do 15 | Post.new do |post| 16 | post.permalink = 'cequel' 17 | post.tags = Set['one', 'two'] 18 | end.tap(&:save) 19 | end 20 | 21 | let! :unloaded_post do 22 | Post['cequel'] 23 | end 24 | 25 | context 'new record' do 26 | it 'should save set as-is' do 27 | subject[:tags].should == Set['one', 'two'] 28 | end 29 | end 30 | 31 | context 'updating' do 32 | it 'should overwrite value' do 33 | post.tags = Set['three', 'four'] 34 | post.save! 35 | subject[:tags].should == Set['three', 'four'] 36 | end 37 | 38 | it 'should cast collection before overwriting' do 39 | post.tags = %w(three four) 40 | post.save! 41 | subject[:tags].should == Set['three', 'four'] 42 | end 43 | end 44 | 45 | describe 'atomic modification' do 46 | before { scope.set_add(:tags, 'three') } 47 | 48 | describe '#add' do 49 | it 'should add atomically' do 50 | post.tags.add('four') 51 | post.save 52 | subject[:tags].should == Set['one', 'two', 'three', 'four'] 53 | expect(post.tags).to eq(Set['one', 'two', 'four']) 54 | end 55 | 56 | it 'should cast before adding' do 57 | post.tags.add(4) 58 | expect(post.tags).to eq(Set['one', 'two', '4']) 59 | end 60 | 61 | it 'should add without reading' do 62 | max_statements! 2 63 | unloaded_post.tags.add('four') 64 | unloaded_post.save 65 | subject[:tags].should == Set['one', 'two', 'three', 'four'] 66 | end 67 | 68 | it 'should apply add post-hoc' do 69 | unloaded_post.tags.add('four') 70 | expect(unloaded_post.tags).to eq(Set['one', 'two', 'three', 'four']) 71 | end 72 | end 73 | 74 | describe '#clear' do 75 | it 'should clear atomically' do 76 | post.tags.clear 77 | post.save 78 | subject[:tags].should be_blank 79 | expect(post.tags).to eq(Set[]) 80 | end 81 | 82 | it 'should clear without reading' do 83 | max_statements! 2 84 | unloaded_post.tags.clear 85 | unloaded_post.save 86 | subject[:tags].should be_blank 87 | end 88 | 89 | it 'should apply clear post-hoc' do 90 | unloaded_post.tags.clear 91 | expect(unloaded_post.tags).to eq(Set[]) 92 | end 93 | end 94 | 95 | describe '#delete' do 96 | it 'should delete atomically' do 97 | post.tags.delete('two') 98 | post.save 99 | subject[:tags].should == Set['one', 'three'] 100 | expect(post.tags).to eq(Set['one']) 101 | end 102 | 103 | it 'should cast before deleting' do 104 | post.tags.delete(:two) 105 | expect(post.tags).to eq(Set['one']) 106 | end 107 | 108 | it 'should delete without reading' do 109 | max_statements! 2 110 | unloaded_post.tags.delete('two') 111 | unloaded_post.save 112 | subject[:tags].should == Set['one', 'three'] 113 | end 114 | 115 | it 'should apply delete post-hoc' do 116 | unloaded_post.tags.delete('two') 117 | expect(unloaded_post.tags).to eq(Set['one', 'three']) 118 | end 119 | end 120 | 121 | describe '#replace' do 122 | it 'should replace atomically' do 123 | post.tags.replace(Set['a', 'b']) 124 | post.save 125 | subject[:tags].should == Set['a', 'b'] 126 | expect(post.tags).to eq(Set['a', 'b']) 127 | end 128 | 129 | it 'should cast before replacing' do 130 | post.tags.replace(Set[1, 2, :three]) 131 | expect(post.tags).to eq(Set['1', '2', 'three']) 132 | end 133 | 134 | it 'should replace without reading' do 135 | max_statements! 2 136 | unloaded_post.tags.replace(Set['a', 'b']) 137 | unloaded_post.save 138 | subject[:tags].should == Set['a', 'b'] 139 | end 140 | 141 | it 'should apply delete post-hoc' do 142 | unloaded_post.tags.replace(Set['a', 'b']) 143 | expect(unloaded_post.tags).to eq(Set['a', 'b']) 144 | end 145 | end 146 | 147 | specify { expect { post.tags.add?('three') }.to raise_error(NoMethodError) } 148 | specify { expect { post.tags.collect!(&:upcase) }. 149 | to raise_error(NoMethodError) } 150 | specify { expect { post.tags.delete?('two') }.to raise_error(NoMethodError) } 151 | specify { expect { post.tags.delete_if { |s| s.starts_with?('t') }}. 152 | to raise_error(NoMethodError) } 153 | specify { expect { post.tags.flatten! }.to raise_error(NoMethodError) } 154 | specify { expect { post.tags.keep_if { |s| s.starts_with?('t') }}. 155 | to raise_error(NoMethodError) } 156 | specify { expect { post.tags.map!(&:upcase) }. 157 | to raise_error(NoMethodError) } 158 | specify { expect { post.tags.reject! { |s| s.starts_with?('t') }}. 159 | to raise_error(NoMethodError) } 160 | specify { expect { post.tags.select! { |s| s.starts_with?('t') }}. 161 | to raise_error(NoMethodError) } 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/examples/record/finders_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require_relative 'spec_helper' 3 | 4 | describe Cequel::Record::Finders do 5 | model :Blog do 6 | key :subdomain, :text 7 | column :name, :text 8 | column :description, :text 9 | column :owner_id, :uuid 10 | end 11 | 12 | model :User do 13 | key :login, :text 14 | column :name, :text 15 | end 16 | 17 | model :Post do 18 | key :blog_subdomain, :text 19 | key :permalink, :text 20 | column :title, :text 21 | column :body, :text 22 | column :author_id, :uuid, index: true 23 | end 24 | 25 | let :blogs do 26 | cequel.batch do 27 | 5.times.map do |i| 28 | Blog.create!(subdomain: "cassandra#{i}", name: 'Cassandra') 29 | end 30 | end 31 | end 32 | 33 | let(:author_ids) { Array.new(2) { Cequel.uuid }} 34 | 35 | let :cassandra_posts do 36 | cequel.batch do 37 | 5.times.map do |i| 38 | Post.create!( 39 | blog_subdomain: 'cassandra', 40 | permalink: "cassandra#{i}", 41 | author_id: author_ids[i%2] 42 | ) 43 | end 44 | end 45 | end 46 | 47 | let :postgres_posts do 48 | cequel.batch do 49 | 5.times.map do |i| 50 | Post.create!(blog_subdomain: 'postgres', permalink: "postgres#{i}") 51 | end 52 | end 53 | end 54 | 55 | let(:posts) { cassandra_posts + postgres_posts } 56 | 57 | context 'simple primary key' do 58 | 59 | let!(:blog) { blogs.first } 60 | 61 | describe '#find_by_*' do 62 | it 'should return matching record' do 63 | expect(Blog.find_by_subdomain('cassandra0')).to eq(blog) 64 | end 65 | 66 | it 'should return nil if no record matches' do 67 | expect(Blog.find_by_subdomain('bogus')).to be_nil 68 | end 69 | 70 | it 'should respond to method before it is called' do 71 | expect(User).to be_respond_to(:find_by_login) 72 | end 73 | 74 | it 'should raise error on wrong name' do 75 | expect { Blog.find_by_bogus('bogus') }.to raise_error(NoMethodError) 76 | end 77 | 78 | it 'should not respond to wrong name' do 79 | expect(User).to_not be_respond_to(:find_by_bogus) 80 | end 81 | end 82 | 83 | describe '#find_all_by_*' do 84 | it 'should raise error if called' do 85 | expect { Blog.find_all_by_subdomain('outoftime') } 86 | .to raise_error(NoMethodError) 87 | end 88 | 89 | it 'should not respond' do 90 | expect(User).not_to be_respond_to(:find_all_by_login) 91 | end 92 | end 93 | end 94 | 95 | context 'compound primary key' do 96 | 97 | let!(:post) { posts.first } 98 | 99 | describe '#find_all_by_*' do 100 | it 'should return all records matching key prefix' do 101 | expect(Post.find_all_by_blog_subdomain('cassandra')) 102 | .to eq(cassandra_posts) 103 | end 104 | 105 | it 'should greedily load records' do 106 | records = Post.find_all_by_blog_subdomain('cassandra') 107 | disallow_queries! 108 | expect(records).to eq(cassandra_posts) 109 | end 110 | 111 | it 'should return empty array if nothing matches' do 112 | expect(Post.find_all_by_blog_subdomain('bogus')).to eq([]) 113 | end 114 | 115 | it 'should not exist for all keys' do 116 | expect { Post.find_all_by_blog_subdomain_and_permalink('f', 'b') } 117 | .to raise_error(NoMethodError) 118 | end 119 | end 120 | 121 | describe '#find_by_*' do 122 | it 'should return record matching all keys' do 123 | expect(Post.find_by_blog_subdomain_and_permalink('cassandra', 124 | 'cassandra0')) 125 | .to eq(cassandra_posts.first) 126 | end 127 | 128 | it 'should not exist for key prefix' do 129 | expect { Post.find_by_blog_subdomain('foo') } 130 | .to raise_error(NoMethodError) 131 | end 132 | 133 | it 'should allow lower-order key if chained' do 134 | expect(Post.where(blog_subdomain: 'cassandra') 135 | .find_by_permalink('cassandra0')).to eq(cassandra_posts.first) 136 | end 137 | end 138 | 139 | describe '#with_*' do 140 | it 'should return record matching all keys' do 141 | expect(Post.with_blog_subdomain_and_permalink('cassandra', 142 | 'cassandra0')) 143 | .to eq(cassandra_posts.first(1)) 144 | end 145 | 146 | it 'should return all records matching key prefix' do 147 | expect(Post.with_blog_subdomain('cassandra')) 148 | .to eq(cassandra_posts) 149 | end 150 | end 151 | end 152 | 153 | context 'secondary index' do 154 | before { cassandra_posts } 155 | 156 | it 'should expose scope to query by secondary index' do 157 | expect(Post.with_author_id(author_ids.first)) 158 | .to match_array(cassandra_posts.values_at(0, 2, 4)) 159 | end 160 | 161 | it 'should expose method to retrieve first result by secondary index' do 162 | expect(Post.find_by_author_id(author_ids.first)) 163 | .to eq(cassandra_posts.first) 164 | end 165 | 166 | it 'should expose method to eagerly retrieve all results by secondary index' do 167 | posts = Post.find_all_by_author_id(author_ids.first) 168 | disallow_queries! 169 | expect(posts).to match_array(cassandra_posts.values_at(0, 2, 4)) 170 | end 171 | 172 | it 'should not expose methods for non-indexed columns' do 173 | [:find_by_title, :find_all_by_title, :with_title].each do |method| 174 | expect(Post).to_not respond_to(method) 175 | end 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/cequel/schema/table_updater.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Schema 4 | # 5 | # Encapsulates a series of schema modification statements that can be 6 | # applied to an existing table 7 | # 8 | class TableUpdater 9 | # 10 | # Construct a table updater and apply the schema modifications to the 11 | # given table. 12 | # 13 | # @param (see #initialize) 14 | # @yieldparam updater [TableUpdater] instance of updater whose 15 | # modifications will be applied to the named table 16 | # @return [void] 17 | # 18 | def self.apply(keyspace, table_name, &block) 19 | new(keyspace, table_name).tap(&block).apply 20 | end 21 | 22 | # 23 | # @param keyspace [Metal::Keyspace] keyspace containing the table 24 | # @param table_name [Symbol] name of the table to modify 25 | # @private 26 | # 27 | def initialize(keyspace, table_name) 28 | @keyspace, @table_name = keyspace, table_name 29 | @statements = [] 30 | end 31 | private_class_method :new 32 | 33 | # 34 | # Apply the schema modifications to the table schema in the database 35 | # 36 | # @return [void] 37 | # 38 | # @api private 39 | # 40 | def apply 41 | statements.each { |statement| keyspace.execute(statement) } 42 | end 43 | 44 | # 45 | # Add a column to the table 46 | # 47 | # @param name [Symbol] the name of the column 48 | # @param type [Symbol,Type] the type of the column 49 | # @return [void] 50 | # 51 | def add_column(name, type) 52 | add_data_column(Column.new(name, type(type))) 53 | end 54 | 55 | # 56 | # Add a list to the table 57 | # 58 | # @param name [Symbol] the name of the list 59 | # @param type [Symbol,Type] the type of the list elements 60 | # @return [void] 61 | # 62 | def add_list(name, type) 63 | add_data_column(List.new(name, type(type))) 64 | end 65 | 66 | # 67 | # Add a set to the table 68 | # 69 | # @param name [Symbol] the name of the set 70 | # @param type [Symbol,Type] the type of the set elements 71 | # @return [void] 72 | # 73 | def add_set(name, type) 74 | add_data_column(Set.new(name, type(type))) 75 | end 76 | 77 | # 78 | # Add a map to the table 79 | # 80 | # @param name [Symbol] the name of the map 81 | # @param key_type [Symbol,Type] the type of the map's keys 82 | # @param value_type [Symbol,Type] the type of the map's values 83 | # @return [void] 84 | # 85 | def add_map(name, key_type, value_type) 86 | add_data_column(Map.new(name, type(key_type), type(value_type))) 87 | end 88 | 89 | # 90 | # Change an existing column's type 91 | # 92 | # @param name [Symbol] the name of the column 93 | # @param type [Symbol,Type] the new type of the column 94 | # @return [void] 95 | # 96 | # @note Changing the type of a CQL column does not modify the data 97 | # currently stored in the column. Thus, client-side handling is needed 98 | # to convert old values to the new type at read time. Cequel does not 99 | # currently support this functionality, although it may in the future. 100 | # Altering column types is not recommended. 101 | # 102 | def change_column(name, type) 103 | alter_table("ALTER #{name} TYPE #{type(type).cql_name}") 104 | end 105 | 106 | # 107 | # Rename a column 108 | # 109 | # @param old_name [Symbol] the current name of the column 110 | # @param new_name [Symbol] the new name of the column 111 | # @return [void] 112 | # 113 | def rename_column(old_name, new_name) 114 | alter_table(%(RENAME "#{old_name}" TO "#{new_name}")) 115 | end 116 | 117 | # 118 | # Change one or more table storage properties 119 | # 120 | # @param options [Hash] map of property names to new values 121 | # @return [void] 122 | # 123 | # @see Table#add_property 124 | # 125 | def change_properties(options) 126 | properties = options 127 | .map { |name, value| TableProperty.build(name, value).to_cql } 128 | alter_table("WITH #{properties.join(' AND ')}") 129 | end 130 | 131 | # 132 | # Create a secondary index 133 | # 134 | # @param column_name [Symbol] name of the column to add an index on 135 | # @param index_name [Symbol] name of the index; will be inferred from 136 | # convention if nil 137 | # @return [void] 138 | # 139 | def create_index(column_name, index_name = nil) 140 | index_name ||= "#{table_name}_#{column_name}_idx" 141 | statements << 142 | "CREATE INDEX #{index_name} ON #{table_name} (#{column_name})" 143 | end 144 | 145 | # 146 | # Remove a secondary index 147 | # 148 | # @param index_name [Symbol] the name of the index to remove 149 | # @return [void] 150 | # 151 | def drop_index(index_name) 152 | statements << "DROP INDEX #{index_name}" 153 | end 154 | 155 | # @!visibility protected 156 | def add_data_column(column) 157 | add_column_statement(column) 158 | end 159 | 160 | protected 161 | 162 | attr_reader :keyspace, :table_name, :statements 163 | 164 | private 165 | 166 | def alter_table(statement) 167 | statements << "ALTER TABLE #{table_name} #{statement}" 168 | end 169 | 170 | def add_column_statement(column) 171 | alter_table("ADD #{column.to_cql}") 172 | end 173 | 174 | def type(type) 175 | ::Cequel::Type[type] 176 | end 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/cequel/schema/keyspace.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Schema 4 | # 5 | # Provides read/write access to the schema for a keyspace and the tables it 6 | # contains 7 | # 8 | # @deprecated These methods will be exposed directly on 9 | # {Cequel::Metal::Keyspace} in a future version of Cequel 10 | # 11 | class Keyspace 12 | # 13 | # @param keyspace [Keyspace] the keyspace whose schema this object 14 | # manipulates 15 | # 16 | # @api private 17 | # 18 | def initialize(keyspace) 19 | @keyspace = keyspace 20 | end 21 | 22 | # 23 | # Create this keyspace in the database 24 | # 25 | # @param options [Options] persistence options for this keyspace. 26 | # @option options [String] :class ("SimpleStrategy") the replication 27 | # strategy to use for this keyspace 28 | # @option options [Integer] :replication_factor (1) the number of 29 | # replicas that should exist for each piece of data 30 | # @return [void] 31 | # 32 | # @see 33 | # http://cassandra.apache.org/doc/cql3/CQL.html#createKeyspaceStmt 34 | # CQL3 CREATE KEYSPACE documentation 35 | # 36 | def create!(options = {}) 37 | bare_connection = 38 | Metal::Keyspace.new(keyspace.configuration.except(:keyspace)) 39 | 40 | options = options.symbolize_keys 41 | options[:class] ||= 'SimpleStrategy' 42 | if options[:class] == 'SimpleStrategy' 43 | options[:replication_factor] ||= 1 44 | end 45 | options_strs = options.map do |name, value| 46 | "'#{name}': #{Cequel::Type.quote(value)}" 47 | end 48 | 49 | bare_connection.execute(<<-CQL) 50 | CREATE KEYSPACE #{keyspace.name} 51 | WITH REPLICATION = {#{options_strs.join(', ')}} 52 | CQL 53 | end 54 | 55 | # 56 | # Drop this keyspace from the database 57 | # 58 | # @return [void] 59 | # 60 | # @see http://cassandra.apache.org/doc/cql3/CQL.html#dropKeyspaceStmt 61 | # CQL3 DROP KEYSPACE documentation 62 | # 63 | def drop! 64 | keyspace.execute("DROP KEYSPACE #{keyspace.name}") 65 | end 66 | 67 | # 68 | # @param name [Symbol] name of the table to read 69 | # @return [Table] object representation of the table schema as it 70 | # currently exists in the database 71 | # 72 | def read_table(name) 73 | TableReader.read(keyspace, name) 74 | end 75 | 76 | # 77 | # Create a table in the keyspace 78 | # 79 | # @param name [Symbol] name of the new table to create 80 | # @yield block evaluated in the context of a {CreateTableDSL} 81 | # @return [void] 82 | # 83 | # @example 84 | # schema.create_table :posts do 85 | # partition_key :blog_subdomain, :text 86 | # key :id, :timeuuid 87 | # 88 | # column :title, :text 89 | # column :body, :text 90 | # column :author_id, :uuid, :index => true 91 | # 92 | # with :caching, :all 93 | # end 94 | # 95 | # @see CreateTableDSL 96 | # 97 | def create_table(name, &block) 98 | table = Table.new(name) 99 | CreateTableDSL.apply(table, &block) 100 | TableWriter.apply(keyspace, table) 101 | end 102 | 103 | # 104 | # Make changes to an existing table in the keyspace 105 | # 106 | # @param name [Symbol] the name of the table to alter 107 | # @yield block evaluated in the context of an {UpdateTableDSL} 108 | # @return [void] 109 | # 110 | # @example 111 | # schema.alter_table :posts do 112 | # add_set :categories, :text 113 | # rename_column :author_id, :author_uuid 114 | # create_index :title 115 | # end 116 | # 117 | # @see UpdateTableDSL 118 | # 119 | def alter_table(name, &block) 120 | updater = TableUpdater.apply(keyspace, name) do |updater| 121 | UpdateTableDSL.apply(updater, &block) 122 | end 123 | end 124 | 125 | # 126 | # Remove all data from this table. Truncating a table can be much slower 127 | # than simply iterating over its keys and issuing `DELETE` statements, 128 | # particularly if the table does not have many rows. Truncating is 129 | # equivalent to dropping a table and then recreating it 130 | # 131 | # @param name [Symbol] name of the table to truncate. 132 | # @return [void] 133 | # 134 | def truncate_table(name) 135 | keyspace.execute("TRUNCATE #{name}") 136 | end 137 | 138 | # 139 | # Drop this table from the keyspace 140 | # 141 | # @param name [Symbol] name of the table to drop 142 | # @return [void] 143 | # 144 | def drop_table(name) 145 | keyspace.execute("DROP TABLE #{name}") 146 | end 147 | 148 | # 149 | # Create or update a table to match a given schema structure. The desired 150 | # schema structure is defined by the directives given in the block; this 151 | # is then compared to the existing table in the database (if it is 152 | # defined at all), and then the table is created or altered accordingly. 153 | # 154 | # @param name [Symbol] name of the table to synchronize 155 | # @yield (see #create_table) 156 | # @return [void] 157 | # @raise (see TableSynchronizer#apply) 158 | # 159 | # @see #create_table Example of DSL usage 160 | # 161 | def sync_table(name, &block) 162 | existing = read_table(name) 163 | updated = Table.new(name) 164 | CreateTableDSL.apply(updated, &block) 165 | TableSynchronizer.apply(keyspace, existing, updated) 166 | end 167 | alias_method :synchronize_table, :sync_table 168 | 169 | protected 170 | 171 | attr_reader :keyspace 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /lib/cequel/schema/table_synchronizer.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Schema 4 | # 5 | # Synchronize a table schema in the database with a desired table schema 6 | # 7 | # @see .apply 8 | # @see Keyspace#synchronize_table 9 | # 10 | class TableSynchronizer 11 | # @return [Table] table as it is currently defined 12 | # @api private 13 | attr_reader :existing 14 | # @return [Table] table schema as it is desired 15 | # @api private 16 | attr_reader :updated 17 | # 18 | # Takes an existing table schema read from the database, and a desired 19 | # schema for that table. Modifies the table schema in the database to 20 | # match the desired schema, or creates the table as specified if it does 21 | # not yet exist 22 | # 23 | # @param keyspace [Metal::Keyspace] keyspace that contains table 24 | # @param existing [Table] table schema as it is currently defined 25 | # @param updated [Table] table schema as it is desired 26 | # @return [void] 27 | # @raise (see #apply) 28 | # 29 | def self.apply(keyspace, existing, updated) 30 | if existing 31 | TableUpdater.apply(keyspace, existing.name) do |updater| 32 | new(updater, existing, updated).apply 33 | end 34 | else 35 | TableWriter.apply(keyspace, updated) 36 | end 37 | end 38 | 39 | # 40 | # @param updater [TableUpdater] table updater to hold schema 41 | # modifications 42 | # @param existing [Table] table schema as it is currently defined 43 | # @param updated [Table] table schema as it is desired 44 | # @return [void] 45 | # @private 46 | # 47 | def initialize(updater, existing, updated) 48 | @updater, @existing, @updated = updater, existing, updated 49 | end 50 | private_class_method :new 51 | 52 | # 53 | # Apply the changes needed to synchronize the schema in the database with 54 | # the desired schema 55 | # 56 | # @return [void] 57 | # @raise (see MigrationValidator#validate!) 58 | # 59 | # @api private 60 | # 61 | def apply 62 | validate! 63 | update_keys 64 | update_columns 65 | update_properties 66 | end 67 | 68 | # 69 | # Iterate over pairs of (old_key, new_key) 70 | # 71 | # @yieldparam old_key [Column] key in existing schema 72 | # @yieldparam new_key [Column] corresponding key in updated schema 73 | # @return [void] 74 | # 75 | # @api private 76 | # 77 | def each_key_pair(&block) 78 | existing.key_columns.zip(updated.key_columns, &block) 79 | end 80 | 81 | # 82 | # Iterate over pairs of (old_column, new_column) 83 | # 84 | # @yieldparam old_column [Column] column in existing schema 85 | # @yieldparam new_column [Column] corresponding column in updated schema 86 | # @return [void] 87 | # 88 | # @api private 89 | # 90 | def each_data_column_pair(&block) 91 | if existing.compact_storage? && existing.clustering_columns.any? 92 | yield existing.data_columns.first, updated.data_columns.first 93 | else 94 | old_columns = existing.data_columns.index_by { |col| col.name } 95 | new_columns = updated.data_columns.index_by { |col| col.name } 96 | all_column_names = (old_columns.keys + new_columns.keys).tap(&:uniq!) 97 | all_column_names.each do |name| 98 | yield old_columns[name], new_columns[name] 99 | end 100 | end 101 | end 102 | 103 | # 104 | # Iterate over pairs of (old_clustering_column, new_clustering_column) 105 | # 106 | # @yieldparam old_clustering_column [Column] key in existing schema 107 | # @yieldparam new_clustering_column [Column] corresponding key in updated 108 | # schema 109 | # @return [void] 110 | # 111 | # @api private 112 | # 113 | def each_clustering_column_pair(&block) 114 | existing.clustering_columns.zip(updated.clustering_columns, &block) 115 | end 116 | 117 | protected 118 | 119 | attr_reader :updater 120 | 121 | private 122 | 123 | def update_keys 124 | each_key_pair do |old_key, new_key| 125 | if old_key.name != new_key.name 126 | updater.rename_column(old_key.name || :column1, new_key.name) 127 | end 128 | end 129 | end 130 | 131 | def update_columns 132 | each_data_column_pair do |old_column, new_column| 133 | if old_column.nil? 134 | add_column(new_column) 135 | elsif new_column 136 | update_column(old_column, new_column) 137 | update_index(old_column, new_column) 138 | end 139 | end 140 | end 141 | 142 | def add_column(column) 143 | updater.add_data_column(column) 144 | updater.create_index(column.name, column.index_name) if column.indexed? 145 | end 146 | 147 | def update_column(old_column, new_column) 148 | if old_column.name != new_column.name 149 | updater.rename_column(old_column.name || :value, new_column.name) 150 | end 151 | if old_column.type != new_column.type 152 | updater.change_column(new_column.name, new_column.type) 153 | end 154 | end 155 | 156 | def update_index(old_column, new_column) 157 | if !old_column.indexed? && new_column.indexed? 158 | updater.create_index(new_column.name, new_column.index_name) 159 | elsif old_column.indexed? && !new_column.indexed? 160 | updater.drop_index(old_column.index_name) 161 | end 162 | end 163 | 164 | def update_properties 165 | changes = {} 166 | updated.properties.each_pair do |name, new_property| 167 | old_property = existing.property(name) 168 | if old_property != new_property.value 169 | changes[name] = new_property.value 170 | end 171 | end 172 | updater.change_properties(changes) if changes.any? 173 | end 174 | 175 | def validate! 176 | MigrationValidator.validate!(self) 177 | end 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /lib/cequel/schema/table_reader.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | module Cequel 3 | module Schema 4 | # 5 | # A TableReader will query Cassandra's internal representation of a table's 6 | # schema, and build a {Table} instance exposing an object representation of 7 | # that schema 8 | # 9 | class TableReader 10 | COMPOSITE_TYPE_PATTERN = 11 | /^org\.apache\.cassandra\.db\.marshal\.CompositeType\((.+)\)$/ 12 | REVERSED_TYPE_PATTERN = 13 | /^org\.apache\.cassandra\.db\.marshal\.ReversedType\((.+)\)$/ 14 | COLLECTION_TYPE_PATTERN = 15 | /^org\.apache\.cassandra\.db\.marshal\.(List|Set|Map)Type\((.+)\)$/ 16 | 17 | # @return [Table] object representation of the table defined in the 18 | # database 19 | attr_reader :table 20 | 21 | # 22 | # Read the schema defined in the database for a given table and return a 23 | # {Table} instance 24 | # 25 | # @param (see #initialize) 26 | # @return (see #read) 27 | # 28 | def self.read(keyspace, table_name) 29 | new(keyspace, table_name).read 30 | end 31 | 32 | # 33 | # @param keyspace [Metal::Keyspace] keyspace to read the table from 34 | # @param table_name [Symbol] name of the table to read 35 | # @private 36 | # 37 | def initialize(keyspace, table_name) 38 | @keyspace, @table_name = keyspace, table_name 39 | @table = Table.new(table_name.to_sym) 40 | end 41 | private_class_method(:new) 42 | 43 | # 44 | # Read table schema from the database 45 | # 46 | # @return [Table] object representation of table in the database, or 47 | # `nil` if no table by given name exists 48 | # 49 | # @api private 50 | # 51 | def read 52 | if table_data.present? 53 | read_partition_keys 54 | read_clustering_columns 55 | read_data_columns 56 | read_properties 57 | table 58 | end 59 | end 60 | 61 | protected 62 | 63 | attr_reader :keyspace, :table_name, :table 64 | 65 | private 66 | 67 | # XXX This gets a lot easier in Cassandra 2.0: all logical columns 68 | # (including keys) are returned from the `schema_columns` query, so 69 | # there's no need to jump through all these hoops to figure out what the 70 | # key columns look like. 71 | # 72 | # However, this approach works for both 1.2 and 2.0, so better to keep it 73 | # for now. It will be worth refactoring this code to take advantage of 74 | # 2.0's better interface in a future version of Cequel that targets 2.0+. 75 | def read_partition_keys 76 | validator = table_data['key_validator'] 77 | types = parse_composite_types(validator) || [validator] 78 | JSON.parse(table_data['key_aliases']).zip(types) do |key_alias, type| 79 | name = key_alias.to_sym 80 | table.add_partition_key(key_alias.to_sym, Type.lookup_internal(type)) 81 | end 82 | end 83 | 84 | # XXX See comment on {read_partition_keys} 85 | def read_clustering_columns 86 | column_aliases = JSON.parse(table_data['column_aliases']) 87 | comparators = parse_composite_types(table_data['comparator']) 88 | unless comparators 89 | table.compact_storage = true 90 | return unless column_data.empty? 91 | column_aliases << :column1 if column_aliases.empty? 92 | comparators = [table_data['comparator']] 93 | end 94 | column_aliases.zip(comparators) do |column_alias, type| 95 | if REVERSED_TYPE_PATTERN =~ type 96 | type = $1 97 | clustering_order = :desc 98 | end 99 | table.add_clustering_column( 100 | column_alias.to_sym, 101 | Type.lookup_internal(type), 102 | clustering_order 103 | ) 104 | end 105 | end 106 | 107 | def read_data_columns 108 | if column_data.empty? 109 | table.add_data_column( 110 | (table_data['value_alias'] || :value).to_sym, 111 | Type.lookup_internal(table_data['default_validator']), 112 | false 113 | ) 114 | else 115 | column_data.each do |result| 116 | if COLLECTION_TYPE_PATTERN =~ result['validator'] 117 | read_collection_column( 118 | result['column_name'], 119 | $1.underscore, 120 | *$2.split(',') 121 | ) 122 | else 123 | table.add_data_column( 124 | result['column_name'].to_sym, 125 | Type.lookup_internal(result['validator']), 126 | result['index_name'].try(:to_sym) 127 | ) 128 | end 129 | end 130 | end 131 | end 132 | 133 | def read_collection_column(name, collection_type, *internal_types) 134 | types = internal_types 135 | .map { |internal| Type.lookup_internal(internal) } 136 | table.__send__("add_#{collection_type}", name.to_sym, *types) 137 | end 138 | 139 | def read_properties 140 | table_data.slice(*Table::STORAGE_PROPERTIES).each do |name, value| 141 | table.add_property(name, value) 142 | end 143 | compaction = JSON.parse(table_data['compaction_strategy_options']) 144 | .symbolize_keys 145 | compaction[:class] = table_data['compaction_strategy_class'] 146 | table.add_property(:compaction, compaction) 147 | compression = JSON.parse(table_data['compression_parameters']) 148 | table.add_property(:compression, compression) 149 | end 150 | 151 | def parse_composite_types(type_string) 152 | if COMPOSITE_TYPE_PATTERN =~ type_string 153 | $1.split(',') 154 | end 155 | end 156 | 157 | def table_data 158 | return @table_data if defined? @table_data 159 | table_query = keyspace.execute(<<-CQL, keyspace.name, table_name) 160 | SELECT * FROM system.schema_columnfamilies 161 | WHERE keyspace_name = ? AND columnfamily_name = ? 162 | CQL 163 | @table_data = table_query.first.try(:to_hash) 164 | end 165 | 166 | def column_data 167 | @column_data ||= 168 | if table_data 169 | column_query = keyspace.execute(<<-CQL, keyspace.name, table_name) 170 | SELECT * FROM system.schema_columns 171 | WHERE keyspace_name = ? AND columnfamily_name = ? 172 | CQL 173 | column_query.map(&:to_hash).select do |column| 174 | !column.key?('type') || column['type'] == 'regular' 175 | end 176 | end 177 | end 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /spec/examples/record/properties_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require File.expand_path('../spec_helper', __FILE__) 3 | 4 | describe Cequel::Record::Properties do 5 | 6 | describe 'property accessors' do 7 | model :Post do 8 | key :permalink, :text 9 | column :title, :text 10 | list :tags, :text 11 | set :categories, :text 12 | map :shares, :text, :int 13 | 14 | def downcased_title=(downcased_title) 15 | self.title = downcased_title.titleize 16 | end 17 | end 18 | 19 | it 'should provide accessor for key' do 20 | Post.new { |post| post.permalink = 'big-data' }.permalink. 21 | should == 'big-data' 22 | end 23 | 24 | it 'should cast key to correct value' do 25 | Post.new { |post| post.permalink = 44 }.permalink. 26 | should == '44' 27 | end 28 | 29 | it 'should have nil key if unset' do 30 | Post.new.permalink.should be_nil 31 | end 32 | 33 | it 'should provide accessor for data column' do 34 | Post.new { |post| post.title = 'Big Data' }.title.should == 'Big Data' 35 | end 36 | 37 | it 'should cast data column to correct value' do 38 | Post.new { |post| post.title = 'Big Data'.force_encoding('US-ASCII') }. 39 | title.encoding.name.should == 'UTF-8' 40 | end 41 | 42 | it 'should have nil data column value if unset' do 43 | Post.new.title.should be_nil 44 | end 45 | 46 | it 'should allow setting attributes via #attributes=' do 47 | Post.new.tap { |post| post.attributes = {:title => 'Big Data' }}. 48 | title.should == 'Big Data' 49 | end 50 | 51 | it 'should use writers when setting attributes' do 52 | Post.new.tap { |post| post.attributes = {:downcased_title => 'big data' }}. 53 | title.should == 'Big Data' 54 | end 55 | 56 | it 'should take attribute arguments to ::new' do 57 | Post.new(:downcased_title => 'big data').title.should == 'Big Data' 58 | end 59 | 60 | it 'should provide accessor for list column' do 61 | expect(Post.new { |post| post.tags = %w(one two three) }.tags).to eq( 62 | %w(one two three)) 63 | end 64 | 65 | it 'should cast collection in list column to list' do 66 | expect(Post.new { |post| post.tags = Set['1', '2', '3'] }.tags) 67 | .to eq(%w(1 2 3)) 68 | end 69 | 70 | it 'should cast elements in list' do 71 | expect(Post.new { |post| post.tags = [1, 2, 3] }.tags).to eq(%w(1 2 3)) 72 | end 73 | 74 | it 'should have empty list column value if unset' do 75 | expect(Post.new.tags).to eq([]) 76 | end 77 | 78 | it 'should have empty list column value if unset in database' do 79 | uniq_key = SecureRandom.uuid 80 | Post.create! permalink: uniq_key 81 | expect(Post[uniq_key].tags).to eq([]) 82 | end 83 | 84 | 85 | it 'should provide accessor for set column' do 86 | expect(Post.new { |post| post.categories = Set['Big Data', 'Cassandra'] } 87 | .categories).to eq(Set['Big Data', 'Cassandra']) 88 | end 89 | 90 | it 'should cast values in set column to correct type' do 91 | expect(Post.new { |post| post.categories = Set[1, 2, 3] }.categories) 92 | .to eq(Set['1', '2', '3']) 93 | end 94 | 95 | it 'should cast collection to set in set column' do 96 | expect(Post.new { |post| post.categories = ['1', '2', '3'] }.categories) 97 | .to eq(Set['1', '2', '3']) 98 | end 99 | 100 | it 'should have empty set column value if not explicitly set' do 101 | expect(Post.new.categories).to eq(Set[]) 102 | end 103 | 104 | it 'should handle saved records with unspecified set properties' do 105 | uuid = SecureRandom.uuid 106 | Post.create!(permalink: uuid) 107 | expect(Post[uuid].categories).to eq(::Set[]) 108 | end 109 | 110 | it 'should provide accessor for map column' do 111 | expect(Post.new { |post| post.shares = {'facebook' => 1, 'twitter' => 2}} 112 | .shares).to eq({'facebook' => 1, 'twitter' => 2}) 113 | end 114 | 115 | it 'should cast values for map column' do 116 | expect(Post.new { |post| post.shares = {facebook: '1', twitter: '2'} } 117 | .shares).to eq({'facebook' => 1, 'twitter' => 2}) 118 | end 119 | 120 | it 'should cast collection passed to map column to map' do 121 | expect(Post.new { |post| post.shares = [['facebook', 1], ['twitter', 2]] } 122 | .shares).to eq({'facebook' => 1, 'twitter' => 2}) 123 | end 124 | 125 | it 'should set map column to empty hash by default' do 126 | expect(Post.new.shares).to eq({}) 127 | end 128 | 129 | it 'should handle saved records with unspecified map properties' do 130 | uuid = SecureRandom.uuid 131 | Post.create!(permalink: uuid) 132 | expect(Post[uuid].shares).to eq({}) 133 | end 134 | 135 | 136 | end 137 | 138 | describe 'configured property defaults' do 139 | model :Post do 140 | key :permalink, :text, :default => 'new_permalink' 141 | column :title, :text, :default => 'New Post' 142 | list :tags, :text, :default => ['new'] 143 | set :categories, :text, :default => Set['Big Data'] 144 | map :shares, :text, :int, :default => {'facebook' => 0} 145 | end 146 | 147 | it 'should respect default for keys' do 148 | Post.new.permalink.should == 'new_permalink' 149 | end 150 | 151 | it 'should respect default for data column' do 152 | Post.new.title.should == 'New Post' 153 | end 154 | 155 | it 'should respect default for list column' do 156 | expect(Post.new.tags).to eq(['new']) 157 | end 158 | 159 | it 'should respect default for set column' do 160 | expect(Post.new.categories).to eq(Set['Big Data']) 161 | end 162 | 163 | it 'should respect default for map column' do 164 | expect(Post.new.shares).to eq({'facebook' => 0}) 165 | end 166 | end 167 | 168 | describe 'dynamic property generation' do 169 | model :Post do 170 | key :id, :uuid, auto: true 171 | key :subid, :text, default: -> { "subid #{1+1}" } 172 | column :title, :text, default: -> { "Post #{Date.today}" } 173 | end 174 | 175 | it 'should auto-generate UUID key' do 176 | Cequel.uuid?(Post.new.id).should be_true 177 | end 178 | 179 | it 'should raise ArgumentError if auto specified for non-UUID' do 180 | expect do 181 | Class.new do 182 | include Cequel::Record 183 | key :subdomain, :text, auto: true 184 | end 185 | end.to raise_error(ArgumentError) 186 | end 187 | 188 | it 'should run default proc on keys' do 189 | Post.new.subid.should == "subid #{1+1}" 190 | end 191 | 192 | it 'should run default proc' do 193 | Post.new.title.should == "Post #{Date.today}" 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /spec/examples/schema/table_synchronizer_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require File.expand_path('../../spec_helper', __FILE__) 3 | 4 | describe Cequel::Schema::TableSynchronizer do 5 | 6 | let(:table) { cequel.schema.read_table(:posts) } 7 | 8 | context 'with no existing table' do 9 | before do 10 | cequel.schema.sync_table :posts do 11 | key :blog_subdomain, :text 12 | key :permalink, :text 13 | column :title, :text 14 | column :body, :text 15 | column :created_at, :timestamp 16 | set :author_names, :text 17 | with :comment, 'Test Table' 18 | end 19 | end 20 | 21 | after { cequel.schema.drop_table(:posts) } 22 | 23 | it 'should create table' do 24 | table.column(:title).type.should == Cequel::Type[:text] #etc. 25 | end 26 | end 27 | 28 | context 'with an existing table' do 29 | before do 30 | cequel.schema.create_table :posts do 31 | key :blog_subdomain, :text 32 | key :permalink, :text 33 | column :title, :ascii, :index => true 34 | column :body, :ascii 35 | column :created_at, :timestamp 36 | set :author_names, :text 37 | with :comment, 'Test Table' 38 | end 39 | end 40 | 41 | after { cequel.schema.drop_table(:posts) } 42 | 43 | context 'with valid changes' do 44 | 45 | before do 46 | cequel.schema.sync_table :posts do 47 | key :blog_subdomain, :text 48 | key :post_permalink, :text 49 | column :title, :ascii 50 | column :body, :text 51 | column :primary_author_id, :uuid, :index => true 52 | column :created_at, :timestamp, :index => true 53 | column :published_at, :timestamp 54 | set :author_names, :text 55 | list :categories, :text 56 | with :comment, 'Test Table 2.0' 57 | end 58 | end 59 | 60 | it 'should rename keys' do 61 | table.clustering_columns.first.name.should == :post_permalink 62 | end 63 | 64 | it 'should add new columns' do 65 | table.column(:published_at).type.should == Cequel::Type[:timestamp] 66 | end 67 | 68 | it 'should add new collections' do 69 | table.column(:categories).should be_a(Cequel::Schema::List) 70 | end 71 | 72 | it 'should add new column with index' do 73 | table.column(:primary_author_id).should be_indexed 74 | end 75 | 76 | it 'should add index to existing columns' do 77 | table.column(:created_at).should be_indexed 78 | end 79 | 80 | it 'should drop index from existing columns' do 81 | table.column(:title).should_not be_indexed 82 | end 83 | 84 | it 'should change column type' do 85 | table.column(:body).type.should == Cequel::Type[:text] 86 | end 87 | 88 | it 'should change properties' do 89 | table.property(:comment).should == 'Test Table 2.0' 90 | end 91 | 92 | end 93 | 94 | context 'invalid migrations' do 95 | 96 | it 'should not allow changing type of key' do 97 | expect { 98 | cequel.schema.sync_table :posts do 99 | key :blog_subdomain, :text 100 | key :permalink, :ascii 101 | column :title, :ascii 102 | column :body, :text 103 | column :created_at, :timestamp 104 | set :author_names, :text 105 | with :comment, 'Test Table' 106 | end 107 | }.to raise_error(Cequel::InvalidSchemaMigration) 108 | end 109 | 110 | it 'should not allow adding a key' do 111 | expect { 112 | cequel.schema.sync_table :posts do 113 | key :blog_subdomain, :text 114 | key :permalink, :text 115 | key :year, :int 116 | column :title, :ascii 117 | column :body, :text 118 | column :created_at, :timestamp 119 | set :author_names, :text 120 | with :comment, 'Test Table' 121 | end 122 | }.to raise_error(Cequel::InvalidSchemaMigration) 123 | end 124 | 125 | it 'should not allow removing a key' do 126 | expect { 127 | cequel.schema.sync_table :posts do 128 | key :blog_subdomain, :text 129 | column :title, :ascii 130 | column :body, :text 131 | column :created_at, :timestamp 132 | set :author_names, :text 133 | with :comment, 'Test Table' 134 | end 135 | }.to raise_error(Cequel::InvalidSchemaMigration) 136 | end 137 | 138 | it 'should not allow changing the partition status of a key' do 139 | expect { 140 | cequel.schema.sync_table :posts do 141 | key :blog_subdomain, :text 142 | partition_key :permalink, :text 143 | column :title, :ascii 144 | column :body, :text 145 | column :created_at, :timestamp 146 | set :author_names, :text 147 | with :comment, 'Test Table' 148 | end 149 | }.to raise_error(Cequel::InvalidSchemaMigration) 150 | end 151 | 152 | it 'should not allow changing the data structure of a column' do 153 | expect { 154 | cequel.schema.sync_table :posts do 155 | key :blog_subdomain, :text 156 | key :permalink, :text 157 | column :title, :ascii 158 | column :body, :text 159 | column :created_at, :timestamp 160 | list :author_names, :text 161 | with :comment, 'Test Table' 162 | end 163 | }.to raise_error(Cequel::InvalidSchemaMigration) 164 | end 165 | 166 | it 'should not allow invalid type transitions of a data column' do 167 | expect { 168 | cequel.schema.sync_table :posts do 169 | key :blog_subdomain, :text 170 | key :permalink, :text 171 | column :title, :ascii, :index => true 172 | column :body, :int 173 | column :created_at, :timestamp 174 | set :author_names, :text 175 | with :comment, 'Test Table' 176 | end 177 | }.to raise_error(Cequel::InvalidSchemaMigration) 178 | end 179 | 180 | it 'should not allow changing clustering order' do 181 | expect { 182 | cequel.schema.sync_table :posts do 183 | key :blog_subdomain, :text 184 | key :permalink, :text, :desc 185 | column :title, :ascii, :index => true 186 | column :body, :ascii 187 | column :created_at, :timestamp 188 | set :author_names, :text 189 | with :comment, 'Test Table' 190 | end 191 | }.to raise_error(Cequel::InvalidSchemaMigration) 192 | end 193 | 194 | end 195 | 196 | 197 | end 198 | 199 | end 200 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # Vagrantfile API/syntax version. Don't touch unless you know what you're doing! 5 | VAGRANTFILE_API_VERSION = "2" 6 | 7 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 8 | # All Vagrant configuration is done here. The most common configuration 9 | # options are documented and commented below. For a complete reference, 10 | # please see the online documentation at vagrantup.com. 11 | 12 | # Every Vagrant virtual environment requires a box to build off of. 13 | config.vm.box = "precise64" 14 | 15 | # The url from where the 'config.vm.box' box will be fetched if it 16 | # doesn't already exist on the user's system. 17 | config.vm.box_url = "http://cloud-images.ubuntu.com/vagrant/precise/current/precise-server-cloudimg-amd64-vagrant-disk1.box" 18 | 19 | # Create a forwarded port mapping which allows access to a specific port 20 | # within the machine from a port on the host machine. In the example below, 21 | # accessing "localhost:8080" will access port 80 on the guest machine. 22 | # config.vm.network :forwarded_port, guest: 80, host: 8080 23 | 24 | # Create a private network, which allows host-only access to the machine 25 | # using a specific IP. 26 | # config.vm.network :private_network, ip: "192.168.33.10" 27 | 28 | # Create a public network, which generally matched to bridged network. 29 | # Bridged networks make the machine appear as another physical device on 30 | # your network. 31 | # config.vm.network :public_network 32 | 33 | # If true, then any SSH connections made will enable agent forwarding. 34 | # Default value: false 35 | # config.ssh.forward_agent = true 36 | 37 | # Share an additional folder to the guest VM. The first argument is 38 | # the path on the host to the actual folder. The second argument is 39 | # the path on the guest to mount the folder. And the optional third 40 | # argument is a set of non-required options. 41 | config.vm.synced_folder ".", "/vagrant", disabled: true 42 | 43 | # Provider-specific configuration so you can fine-tune various 44 | # backing providers for Vagrant. These expose provider-specific options. 45 | # Example for VirtualBox: 46 | # 47 | # config.vm.provider :virtualbox do |vb| 48 | # # Don't boot with headless mode 49 | # vb.gui = true 50 | # 51 | # # Use VBoxManage to customize the VM. For example to change memory: 52 | # vb.customize ["modifyvm", :id, "--memory", "1024"] 53 | # end 54 | # 55 | # View the documentation for the provider you're using for more 56 | # information on available options. 57 | 58 | # Enable provisioning with Puppet stand alone. Puppet manifests 59 | # are contained in a directory path relative to this Vagrantfile. 60 | # You will need to create the manifests directory and a manifest in 61 | # the file base.pp in the manifests_path directory. 62 | # 63 | # An example Puppet manifest to provision the message of the day: 64 | # 65 | # # group { "puppet": 66 | # # ensure => "present", 67 | # # } 68 | # # 69 | # # File { owner => 0, group => 0, mode => 0644 } 70 | # # 71 | # # file { '/etc/motd': 72 | # # content => "Welcome to your Vagrant-built virtual machine! 73 | # # Managed by Puppet.\n" 74 | # # } 75 | # 76 | # config.vm.provision :puppet do |puppet| 77 | # puppet.manifests_path = "manifests" 78 | # puppet.manifest_file = "site.pp" 79 | # end 80 | 81 | # Enable provisioning with chef solo, specifying a cookbooks path, roles 82 | # path, and data_bags path (all relative to this Vagrantfile), and adding 83 | # some recipes and/or roles. 84 | # 85 | # config.vm.provision :chef_solo do |chef| 86 | # chef.cookbooks_path = "../my-recipes/cookbooks" 87 | # chef.roles_path = "../my-recipes/roles" 88 | # chef.data_bags_path = "../my-recipes/data_bags" 89 | # chef.add_recipe "mysql" 90 | # chef.add_role "web" 91 | # 92 | # # You may also specify custom JSON attributes: 93 | # chef.json = { :mysql_password => "foo" } 94 | # end 95 | 96 | # Enable provisioning with chef server, specifying the chef server URL, 97 | # and the path to the validation key (relative to this Vagrantfile). 98 | # 99 | # The Opscode Platform uses HTTPS. Substitute your organization for 100 | # ORGNAME in the URL and validation key. 101 | # 102 | # If you have your own Chef Server, use the appropriate URL, which may be 103 | # HTTP instead of HTTPS depending on your configuration. Also change the 104 | # validation key to validation.pem. 105 | # 106 | # config.vm.provision :chef_client do |chef| 107 | # chef.chef_server_url = "https://api.opscode.com/organizations/ORGNAME" 108 | # chef.validation_key_path = "ORGNAME-validator.pem" 109 | # end 110 | # 111 | # If you're using the Opscode platform, your validator client is 112 | # ORGNAME-validator, replacing ORGNAME with your organization name. 113 | # 114 | # If you have your own Chef Server, the default validation client name is 115 | # chef-validator, unless you changed the configuration. 116 | # 117 | # chef.validation_client_name = "ORGNAME-validator" 118 | provision = <<-SH 119 | set -e 120 | apt-get update 121 | env DEBIAN_FRONTEND=noninteractive apt-get upgrade -y 122 | apt-get install python-software-properties curl -y 123 | apt-get autoremove -y 124 | add-apt-repository -y ppa:webupd8team/java 125 | apt-get update 126 | echo debconf shared/accepted-oracle-license-v1-1 select true | sudo debconf-set-selections 127 | echo debconf shared/accepted-oracle-license-v1-1 seen true | sudo debconf-set-selections 128 | apt-get install -y oracle-java$2-installer oracle-java$2-set-default 129 | curl -s http://archive.apache.org/dist/cassandra/$1/apache-cassandra-$1-bin.tar.gz | tar -C /opt -xzvf - 130 | echo "start on runlevel [2345] 131 | stop on runlevel [06] 132 | exec /opt/apache-cassandra-$1/bin/cassandra" > /etc/init/cassandra.conf 133 | sed -i -e 's/rpc_address:.*/rpc_address: 0.0.0.0/' /opt/apache-cassandra-$1/conf/cassandra.yaml 134 | sed -i -e 's/start_native_transport:.*/start_native_transport: true/' /opt/apache-cassandra-$1/conf/cassandra.yaml 135 | sed -i -e 's/start_rpc:.*/start_rpc: true/' /opt/apache-cassandra-$1/conf/cassandra.yaml 136 | service cassandra start 137 | SH 138 | 139 | versions = (0..7).map { |p| "2.0.#{p}" } + (0..16).map { |p| "1.2.#{p}" } 140 | versions.each do |version| 141 | java_version = version =~ /^1/ ? '6' : '7' 142 | config.vm.define version do |machine| 143 | machine.vm.provision :shell, inline: provision, 144 | args: [version, java_version] 145 | machine.vm.network :forwarded_port, guest: 9042, host: 9042, 146 | auto_correct: true 147 | machine.vm.network :forwarded_port, guest: 9160, host: 9160, 148 | auto_correct: true 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/shared/readable_dictionary.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | shared_examples 'readable dictionary' do 3 | let(:uuid1) { uuid } 4 | let(:uuid2) { uuid } 5 | let(:uuid3) { uuid } 6 | let(:cf) { dictionary.class.column_family.column_family } 7 | 8 | context 'without row in memory' do 9 | 10 | describe '#each_pair' do 11 | 12 | it 'should load columns in batches and yield them' do 13 | connection.should_receive(:execute). 14 | with("SELECT FIRST 2 * FROM #{cf} WHERE ? = ? LIMIT 1", :blog_id, 1). 15 | and_return result_stub('blog_id' => 1, uuid1 => 1, uuid2 => 2) 16 | connection.should_receive(:execute). 17 | with("SELECT FIRST 2 ?..? FROM #{cf} WHERE ? = ? LIMIT 1", uuid2, '', :blog_id, 1). 18 | and_return result_stub('blog_id' => 1, uuid2 => 2, uuid3 => 3) 19 | connection.should_receive(:execute). 20 | with("SELECT FIRST 2 ?..? FROM #{cf} WHERE ? = ? LIMIT 1", uuid3, '', :blog_id, 1). 21 | and_return result_stub({'blog_id' => 1}) 22 | hash = {} 23 | dictionary.each_pair do |key, value| 24 | hash[key] = value 25 | end 26 | hash.should == {uuid1 => 1, uuid2 => 2, uuid3 => 3} 27 | end 28 | 29 | end 30 | 31 | describe '#[]' do 32 | 33 | it 'should load column from cassandra' do 34 | connection.stub(:execute). 35 | with("SELECT ? FROM #{cf} WHERE ? = ? LIMIT 1", [uuid1], :blog_id, 1). 36 | and_return result_stub(uuid1 => 1) 37 | dictionary[uuid1].should == 1 38 | end 39 | 40 | end 41 | 42 | describe '#slice' do 43 | it 'should load columns from data store' do 44 | connection.stub(:execute). 45 | with("SELECT ? FROM #{cf} WHERE ? = ? LIMIT 1", [uuid1,uuid2], :blog_id, 1). 46 | and_return result_stub(uuid1 => 1, uuid2 => 2) 47 | dictionary.slice(uuid1, uuid2).should == {uuid1 => 1, uuid2 => 2} 48 | end 49 | end 50 | 51 | describe '#keys' do 52 | it 'should load keys from data store' do 53 | connection.should_receive(:execute). 54 | with("SELECT FIRST 2 * FROM #{cf} WHERE ? = ? LIMIT 1", :blog_id, 1). 55 | and_return result_stub('blog_id' => 1, uuid1 => 1, uuid2 => 2) 56 | connection.should_receive(:execute). 57 | with("SELECT FIRST 2 ?..? FROM #{cf} WHERE ? = ? LIMIT 1", uuid2, '', :blog_id, 1). 58 | and_return result_stub('blog_id' => 1, uuid2 => 2, uuid3 => 3) 59 | connection.should_receive(:execute). 60 | with("SELECT FIRST 2 ?..? FROM #{cf} WHERE ? = ? LIMIT 1", uuid3, '', :blog_id, 1). 61 | and_return result_stub({'blog_id' => 1}) 62 | dictionary.keys.should == [uuid1, uuid2, uuid3] 63 | end 64 | end 65 | 66 | describe '#values' do 67 | it 'should load values from data store' do 68 | connection.should_receive(:execute). 69 | with("SELECT FIRST 2 * FROM #{cf} WHERE ? = ? LIMIT 1", :blog_id, 1). 70 | and_return result_stub('blog_id' => 1, uuid1 => 1, uuid2 => 2) 71 | connection.should_receive(:execute). 72 | with("SELECT FIRST 2 ?..? FROM #{cf} WHERE ? = ? LIMIT 1", uuid2, '', :blog_id, 1). 73 | and_return result_stub('blog_id' => 1, uuid2 => 2, uuid3 => 3) 74 | connection.should_receive(:execute). 75 | with("SELECT FIRST 2 ?..? FROM #{cf} WHERE ? = ? LIMIT 1", uuid3, '', :blog_id, 1). 76 | and_return result_stub({'blog_id' => 1}) 77 | dictionary.values.should == [1, 2, 3] 78 | end 79 | end 80 | 81 | describe '#first' do 82 | it 'should load value from data store' do 83 | connection.should_receive(:execute). 84 | with("SELECT FIRST 1 * FROM #{cf} WHERE ? = ? LIMIT 1", :blog_id, 1). 85 | and_return result_stub('blog_id' => 1, uuid1 => 1) 86 | dictionary.first.should == [uuid1, 1] 87 | end 88 | end 89 | 90 | describe '#last' do 91 | it 'should load value from data store' do 92 | connection.should_receive(:execute). 93 | with("SELECT FIRST 1 REVERSED * FROM #{cf} WHERE ? = ? LIMIT 1", :blog_id, 1). 94 | and_return result_stub('blog_id' => 1, uuid3 => 3) 95 | dictionary.last.should == [uuid3, 3] 96 | end 97 | end 98 | 99 | end 100 | 101 | context 'with data loaded in memory' do 102 | before do 103 | connection.stub(:execute). 104 | with("SELECT FIRST 2 * FROM #{cf} WHERE ? = ? LIMIT 1", :blog_id, 1). 105 | and_return result_stub('blog_id' => 1, uuid1 => 1, uuid2 => 2) 106 | connection.stub(:execute). 107 | with("SELECT FIRST 2 ?..? FROM #{cf} WHERE ? = ? LIMIT 1", uuid2, '', :blog_id, 1). 108 | and_return result_stub('blog_id' => 1, uuid2 => 2, uuid3 => 3) 109 | connection.stub(:execute). 110 | with("SELECT FIRST 2 ?..? FROM #{cf} WHERE ? = ? LIMIT 1", uuid3, '', :blog_id, 1). 111 | and_return result_stub({'blog_id' => 1}) 112 | dictionary.load 113 | connection.should_not_receive(:execute) 114 | end 115 | 116 | describe '#each_pair' do 117 | it 'should yield data from memory' do 118 | hash = {} 119 | dictionary.each_pair do |key, value| 120 | hash[key] = value 121 | end 122 | hash.should == {uuid1 => 1, uuid2 => 2, uuid3 => 3} 123 | end 124 | end 125 | 126 | describe '#[]' do 127 | it 'should return value from memory' do 128 | dictionary[uuid1].should == 1 129 | end 130 | end 131 | 132 | describe '#slice' do 133 | it 'should return slice of data in memory' do 134 | dictionary.slice(uuid1, uuid2).should == {uuid1 => 1, uuid2 => 2} 135 | end 136 | end 137 | 138 | describe '#first' do 139 | it 'should return first element in memory' do 140 | dictionary.first.should == [uuid1, 1] 141 | end 142 | end 143 | 144 | describe '#last' do 145 | it 'should return first element in memory' do 146 | dictionary.last.should == [uuid3, 3] 147 | end 148 | end 149 | 150 | describe '#keys' do 151 | it 'should return keys from memory' do 152 | dictionary.keys.should == [uuid1, uuid2, uuid3] 153 | end 154 | end 155 | 156 | describe '#values' do 157 | it 'should return values from memory' do 158 | dictionary.values.should == [1, 2, 3] 159 | end 160 | end 161 | end 162 | 163 | describe '::load' do 164 | let :comments do 165 | [ 166 | {'user' => 'Cequel User', 'comment' => 'How do I load multiple rows?'}, 167 | {'user' => 'Mat Brown', 'comment' => 'Just use the ::load class method'} 168 | ] 169 | end 170 | 171 | it 'should load all rows in one query' do 172 | connection.stub(:execute). 173 | with( 174 | 'SELECT * FROM post_comments WHERE ? IN (?)', 175 | 'post_id', [1, 2] 176 | ).and_return result_stub( 177 | *comments.each_with_index. 178 | map { |comment, i| {'post_id' => i+1, i+4 => comment.to_json} } 179 | ) 180 | rows = PostComments.load(1, 2) 181 | rows.map { |row| row.post_id }.should == [1, 2] 182 | rows.map { |row| row.values.first }.should == comments 183 | end 184 | end 185 | 186 | private 187 | 188 | def uuid 189 | SimpleUUID::UUID.new.to_guid 190 | end 191 | end 192 | 193 | -------------------------------------------------------------------------------- /spec/examples/schema/table_writer_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require File.expand_path('../../spec_helper', __FILE__) 3 | 4 | describe Cequel::Schema::TableWriter do 5 | 6 | let(:table) { cequel.schema.read_table(:posts) } 7 | 8 | describe '#create_table' do 9 | 10 | after do 11 | cequel.schema.drop_table(:posts) 12 | end 13 | 14 | describe 'with simple skinny table' do 15 | before do 16 | cequel.schema.create_table(:posts) do 17 | key :permalink, :ascii 18 | column :title, :text 19 | end 20 | end 21 | 22 | it 'should create key alias' do 23 | table.partition_key_columns.map(&:name).should == [:permalink] 24 | end 25 | 26 | it 'should set key validator' do 27 | table.partition_key_columns.map(&:type).should == [Cequel::Type[:ascii]] 28 | end 29 | 30 | it 'should set non-key columns' do 31 | table.columns.find { |column| column.name == :title }.type. 32 | should == Cequel::Type[:text] 33 | end 34 | end 35 | 36 | describe 'with multi-column primary key' do 37 | before do 38 | cequel.schema.create_table(:posts) do 39 | key :blog_subdomain, :ascii 40 | key :permalink, :ascii 41 | column :title, :text 42 | end 43 | end 44 | 45 | it 'should create key alias' do 46 | table.partition_key_columns.map(&:name).should == [:blog_subdomain] 47 | end 48 | 49 | it 'should set key validator' do 50 | table.partition_key_columns.map(&:type).should == [Cequel::Type[:ascii]] 51 | end 52 | 53 | it 'should create non-partition key components' do 54 | table.clustering_columns.map(&:name).should == [:permalink] 55 | end 56 | 57 | it 'should set type for non-partition key components' do 58 | table.clustering_columns.map(&:type).should == [Cequel::Type[:ascii]] 59 | end 60 | end 61 | 62 | describe 'with composite partition key' do 63 | before do 64 | cequel.schema.create_table(:posts) do 65 | partition_key :blog_subdomain, :ascii 66 | partition_key :permalink, :ascii 67 | column :title, :text 68 | end 69 | end 70 | 71 | it 'should create all partition key components' do 72 | table.partition_key_columns.map(&:name).should == [:blog_subdomain, :permalink] 73 | end 74 | 75 | it 'should set key validators' do 76 | table.partition_key_columns.map(&:type). 77 | should == [Cequel::Type[:ascii], Cequel::Type[:ascii]] 78 | end 79 | end 80 | 81 | describe 'with composite partition key and non-partition keys' do 82 | before do 83 | cequel.schema.create_table(:posts) do 84 | partition_key :blog_subdomain, :ascii 85 | partition_key :permalink, :ascii 86 | key :month, :timestamp 87 | column :title, :text 88 | end 89 | end 90 | 91 | it 'should create all partition key components' do 92 | table.partition_key_columns.map(&:name). 93 | should == [:blog_subdomain, :permalink] 94 | end 95 | 96 | it 'should set key validators' do 97 | table.partition_key_columns.map(&:type). 98 | should == [Cequel::Type[:ascii], Cequel::Type[:ascii]] 99 | end 100 | 101 | it 'should create non-partition key components' do 102 | table.clustering_columns.map(&:name).should == [:month] 103 | end 104 | 105 | it 'should set type for non-partition key components' do 106 | table.clustering_columns.map(&:type).should == [Cequel::Type[:timestamp]] 107 | end 108 | end 109 | 110 | describe 'collection types' do 111 | before do 112 | cequel.schema.create_table(:posts) do 113 | key :permalink, :ascii 114 | column :title, :text 115 | list :authors, :blob 116 | set :tags, :text 117 | map :trackbacks, :timestamp, :ascii 118 | end 119 | end 120 | 121 | it 'should create list' do 122 | table.data_column(:authors).should be_a(Cequel::Schema::List) 123 | end 124 | 125 | it 'should set correct type for list' do 126 | table.data_column(:authors).type.should == Cequel::Type[:blob] 127 | end 128 | 129 | it 'should create set' do 130 | table.data_column(:tags).should be_a(Cequel::Schema::Set) 131 | end 132 | 133 | it 'should set correct type for set' do 134 | table.data_column(:tags).type.should == Cequel::Type[:text] 135 | end 136 | 137 | it 'should create map' do 138 | table.data_column(:trackbacks).should be_a(Cequel::Schema::Map) 139 | end 140 | 141 | it 'should set correct key type' do 142 | table.data_column(:trackbacks).key_type. 143 | should == Cequel::Type[:timestamp] 144 | end 145 | 146 | it 'should set correct value type' do 147 | table.data_column(:trackbacks).value_type. 148 | should == Cequel::Type[:ascii] 149 | end 150 | end 151 | 152 | describe 'storage properties' do 153 | before do 154 | cequel.schema.create_table(:posts) do 155 | key :permalink, :ascii 156 | column :title, :text 157 | with :comment, 'Blog posts' 158 | with :compression, 159 | :sstable_compression => "DeflateCompressor", 160 | :chunk_length_kb => 64 161 | end 162 | end 163 | 164 | it 'should set simple properties' do 165 | table.property(:comment).should == 'Blog posts' 166 | end 167 | 168 | it 'should set map collection properties' do 169 | table.property(:compression).should == { 170 | :sstable_compression => 'DeflateCompressor', 171 | :chunk_length_kb => 64 172 | } 173 | end 174 | end 175 | 176 | describe 'compact storage' do 177 | before do 178 | cequel.schema.create_table(:posts) do 179 | key :permalink, :ascii 180 | column :title, :text 181 | compact_storage 182 | end 183 | end 184 | 185 | it 'should have compact storage' do 186 | table.should be_compact_storage 187 | end 188 | end 189 | 190 | describe 'clustering order' do 191 | before do 192 | cequel.schema.create_table(:posts) do 193 | key :blog_permalink, :ascii 194 | key :id, :uuid, :desc 195 | column :title, :text 196 | end 197 | end 198 | 199 | it 'should set clustering order' do 200 | table.clustering_columns.map(&:clustering_order).should == [:desc] 201 | end 202 | end 203 | 204 | describe 'indices' do 205 | it 'should create indices' do 206 | cequel.schema.create_table(:posts) do 207 | key :blog_permalink, :ascii 208 | key :id, :uuid, :desc 209 | column :title, :text, :index => true 210 | end 211 | table.data_column(:title).should be_indexed 212 | end 213 | 214 | it 'should create indices with specified name' do 215 | cequel.schema.create_table(:posts) do 216 | key :blog_permalink, :ascii 217 | key :id, :uuid, :desc 218 | column :title, :text, :index => :silly_idx 219 | end 220 | table.data_column(:title).index_name.should == :silly_idx 221 | end 222 | end 223 | 224 | end 225 | 226 | end 227 | --------------------------------------------------------------------------------