cql-model 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,50 @@
1
+ module Cql::Model::InstanceMethods
2
+ # Instantiate a new instance of this model. Do not validate the contents of
3
+ # cql_properties; it may contain properties that aren't declared by this model, that have
4
+ # a missing CQL column, or an improper name/value for their column type.
5
+ #
6
+ # @param [Hash] cql_properties typed hash of all properties associated with this model
7
+ def initialize(cql_properties=nil)
8
+ @cql_properties = cql_properties || {}
9
+ end
10
+
11
+ # Read a property. Property names are column names, and can therefore take any data type
12
+ # that a column name can take (integer, UUID, etc).
13
+ #
14
+ # @param [Object] name
15
+ # @return [Object] the value of the specified column, or nil
16
+ def [](name)
17
+ @cql_properties[name]
18
+ end
19
+
20
+ # Start an INSERT CQL statement to update model
21
+ # @see Cql::Model::Query::InsertStatement
22
+ #
23
+ # @param [Hash] values Hash of column values indexed by column name, optional
24
+ # @return [Cql::Model::Query::InsertStatement] a query object to customize (ttl, timestamp etc) or execute
25
+ #
26
+ # @example
27
+ # joe.update(:age => 35).execute
28
+ # joe.update.ttl(3600).execute
29
+ # joe.update(:age => 36).ttl(7200).consistency('ONE').execute
30
+ def update(values={})
31
+ key_vals = self.class.primary_key.inject({}) { |h, k| h[k] = @cql_properties[k]; h }
32
+ Cql::Model::Query::UpdateStatement.new(self.class).update(values.merge(key_vals))
33
+ end
34
+
35
+ # Start an UPDATE CQL statement to update all models with given key
36
+ # This can update multiple models if the key is part of a composite key
37
+ # Updating all models with given (different) key values can be done using the '.update' class method
38
+ # @see Cql::Model::Query::UpdateStatement
39
+ #
40
+ # @param [Symbol|String] key Name of key used to select models to be updated
41
+ # @param [Hash] values Hash of column values indexed by column names, optional
42
+ # @return [Cql::Model::Query::UpdateStatement] a query object to customize (ttl, timestamp etc) or execute
43
+ #
44
+ # @example
45
+ # joe.update_all_by(:name, :age => 25).execute # Set all joe's age to 25
46
+ # joe.update_all_by(:name).ttl(3600).execute # Set all joe's TTL to one hour
47
+ def update_all_by(key, values={})
48
+ Cql::Model::Query::UpdateStatement.new(self.class).update(values.merge({ key => @cql_properties[key.to_s] }))
49
+ end
50
+ end
@@ -0,0 +1,107 @@
1
+ module Cql::Model::Query
2
+ # CQL single quote character.
3
+ SQ = "'"
4
+
5
+ # CQL single-quote escape sequence.
6
+ SQSQ = "''"
7
+
8
+ # CQL double-quote character.
9
+ DQ = '"'
10
+
11
+ # CQL double-quote escape.
12
+ DQDQ = '""'
13
+
14
+ # Valid CQL identifier (can be used as a column name without double-quoting)
15
+ IDENTIFIER = /[a-z][a-z0-9_]*/i
16
+
17
+ module_function
18
+
19
+ # Transform a list of symbols or strings into CQL column names. Performs no safety checks!!
20
+ def cql_column_names(list)
21
+ if list.empty?
22
+ '*'
23
+ else
24
+ list.join(', ')
25
+ end
26
+ end
27
+
28
+ # Transform a Ruby object into its CQL identifier representation.
29
+ #
30
+ # @TODO more docs
31
+ #
32
+ def cql_identifier(value)
33
+ # TODO UUID, Time, ...
34
+ case value
35
+ when Symbol, String
36
+ if value =~ IDENTIFIER
37
+ value.to_s
38
+ else
39
+ "#{DQ}#{value.gsub(DQ, DQDQ)}#{DQ}"
40
+ end
41
+ when Numeric, TrueClass, FalseClass
42
+ "#{DQ}#{cql_value(value)}#{DQ}"
43
+ else
44
+ raise Cql::Model::SyntaxError, "Cannot convert #{value.class} to a CQL identifier"
45
+ end
46
+ end
47
+
48
+ # Transform a Ruby object into its CQL literal value representation. A literal value is anything
49
+ # that can appear in a CQL statement as a key or column value (but not column NAME; see
50
+ # #cql_identifier to convert values to column names).
51
+ #
52
+ # @param value [String,Numeric,Boolean,Array,Set,Array,#map]
53
+ # @return [String] the CQL equivalent of value
54
+ #
55
+ # When used as a key or column value, CQL supports the following kinds of literal value:
56
+ # * unquoted identifier (treated as a string value)
57
+ # * string literal
58
+ # * integer number
59
+ # * UUID
60
+ # * floating-point number
61
+ # * boolean true/false
62
+ #
63
+ # When used as a column name, any value that is not a valid identifier MUST BE ENCLOSED IN
64
+ # DOUBLE QUOTES. This method does not handle the double-quote escaping; see #cql_identifier
65
+ # for that.
66
+ #
67
+ # @see #cql_identifier
68
+ # @see http://www.datastax.com/docs/1.1/references/cql/cql_lexicon#keywords-and-identifiers
69
+ def cql_value(value, context=nil)
70
+ # TODO UUID, Time, ...
71
+ case value
72
+ when String
73
+ "#{SQ}#{value.gsub(SQ, SQSQ)}#{SQ}"
74
+ when Numeric, TrueClass, FalseClass
75
+ value.to_s
76
+ when Set
77
+ raise SyntaxError, "Set notation is not allowed outside UPDATE statements" unless (context == :update)
78
+ '{' + value.map { |v| cql_value(v) }.join(', ') + '}'
79
+ else
80
+ if value.respond_to?(:map)
81
+ if value.respond_to?(:each_pair)
82
+ # Pairwise map -- CQL map literal
83
+ '{' + value.map { |k, v| "#{cql_value(k)}: #{cql_value(v)}" }.join(', ') + '}'
84
+ else
85
+ # Single map -- CQL list (for UPDATE) or set (for WHERE...IN) literal
86
+ case context
87
+ when :update
88
+ '[' + value.map { |v| cql_value(v) }.join(', ') + ']'
89
+ else
90
+ '(' + value.map { |v| cql_value(v) }.join(', ') + ')'
91
+ end
92
+ end
93
+ else
94
+ raise Cql::Model::SyntaxError, "Cannot convert #{value.class} to a CQL value"
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ require 'cql/model/query/expression'
101
+ require 'cql/model/query/comparison_expression'
102
+ require 'cql/model/query/update_expression'
103
+ require 'cql/model/query/statement'
104
+ require 'cql/model/query/mutation_statement'
105
+ require 'cql/model/query/select_statement'
106
+ require 'cql/model/query/insert_statement'
107
+ require 'cql/model/query/update_statement'
@@ -0,0 +1,126 @@
1
+ require 'set'
2
+
3
+ module Cql::Model::Query
4
+ # @TODO docs
5
+ class ComparisonExpression < Expression
6
+ # Operators allowed in a where-clause lambda
7
+ OPERATORS = {
8
+ :== => '=',
9
+ :'!=' => '!=',
10
+ :'>' => '>',
11
+ :'<' => '<',
12
+ :'>=' => '>=',
13
+ :'<=' => '<=',
14
+ :'in' => 'IN',
15
+ }.freeze
16
+
17
+ # Methods used to escape CQL column names that aren't valid CQL identifiers
18
+ TYPECASTS = [
19
+ :ascii,
20
+ :bigint,
21
+ :blob,
22
+ :boolean,
23
+ :counter,
24
+ :decimal,
25
+ :double,
26
+ :float,
27
+ :int,
28
+ :text,
29
+ :timestamp,
30
+ :uuid,
31
+ :timeuuid,
32
+ :varchar,
33
+ :varint
34
+ ].freeze
35
+
36
+ # @TODO docs
37
+ def initialize(*params, &block)
38
+ @left = nil
39
+ @operator = nil
40
+ @right = nil
41
+
42
+ instance_exec(*params, &block) if block
43
+ end
44
+
45
+ # @TODO docs
46
+ def to_s
47
+ __build__
48
+ end
49
+
50
+ # @TODO docs
51
+ def inspect
52
+ __build__
53
+ end
54
+
55
+ # This is where the magic happens. Ensure all of our operators are overloaded so they call
56
+ # #apply and contribute to the CQL expression that will be built.
57
+ OPERATORS.keys.each do |op|
58
+ define_method(op) do |*args|
59
+ __apply__(op, args)
60
+ end
61
+ end
62
+
63
+ TYPECASTS.each do |func|
64
+ define_method(func) do |*args|
65
+ __apply__(func, args)
66
+ end
67
+ end
68
+
69
+ # @TODO docs
70
+ def method_missing(token, *args)
71
+ __apply__(token, args)
72
+ end
73
+
74
+ private
75
+
76
+ # @TODO docs
77
+ def __apply__(token, args)
78
+ if @left.nil?
79
+ if args.empty?
80
+ # A well-behaved CQL identifier (column name that is a valid Ruby method name)
81
+ @left = token
82
+ elsif args.length == 1
83
+ # A CQL typecast (column name that is an integer, float, etc and must be wrapped in a decorator)
84
+ @left = args.first
85
+ else
86
+ ::Kernel.raise ::Cql::Model::SyntaxError.new(
87
+ "Unacceptable token '#{token}'; expected a CQL identifier or typecast")
88
+ end
89
+ elsif @operator.nil?
90
+ # Looking for an operator + right operand
91
+ if OPERATORS.keys.include?(token)
92
+ @operator = token
93
+
94
+ if (args.size > 1) || (token == :in)
95
+ @right = args
96
+ else
97
+ @right = args.first
98
+ end
99
+ else
100
+ ::Kernel.raise ::Cql::Model::SyntaxError.new(
101
+ "Unacceptable token '#{token}'; expected a CQL-compatible operator")
102
+ end
103
+ else
104
+ ::Kernel.raise ::Cql::Model::SyntaxError.new(
105
+ "Unacceptable token '#{token}'; the expression is " +
106
+ "already complete")
107
+ end
108
+
109
+ self
110
+ end
111
+
112
+ # @TODO docs
113
+ def __build__
114
+ if @left.nil? || @operator.nil? || @right.nil?
115
+ ::Kernel.raise ::Cql::Model::SyntaxError.new(
116
+ "Cannot build a CQL expression; the Ruby expression is incomplete " +
117
+ "(#{@left.inspect}, #{@operator.inspect}, #{@right.inspect})")
118
+ else
119
+ left = ::Cql::Model::Query.cql_identifier(@left)
120
+ op = OPERATORS[@operator]
121
+ right = ::Cql::Model::Query.cql_value(@right)
122
+ "#{left} #{op} #{right}"
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,5 @@
1
+ module Cql::Model::Query
2
+ class Expression < BasicObject
3
+
4
+ end
5
+ end
@@ -0,0 +1,51 @@
1
+ module Cql::Model::Query
2
+
3
+ # INSERT statement DSL
4
+ # << An INSERT writes one or more columns to a record in a Cassandra column family. No results are returned.
5
+ # The first column name in the INSERT list must be the name of the column family key >>
6
+ # (from: http://www.datastax.com/docs/1.1/references/cql/INSERT)
7
+ #
8
+ # Ex:
9
+ # Model.create(:key => 'val', :col1 => 'value', :col2 => 42) # Simple insert
10
+ # Model.create(:key => 'val', :key2 => 64, :col1 => 'value', :col2 => 42) # Composite keys
11
+ # Model.create(:key => 'val', :col => 'value').ttl(3600) # TTL in seconds
12
+ # Model.create(:key => 'val', :col => 'value').timestamp(1366057256324) # Milliseconds since epoch timestamp
13
+ # Model.create(:key => 'val', :col => 'value').timestamp('2013-04-15 13:21:48') # ISO 8601 timestamp
14
+ # Model.create(:key => 'val', :col => 'value').consistency('ONE') # Custom consistency (default is 'LOCAL_QUORUM')
15
+ # Model.create(:key => 'val', :col => 'value').ttl(3600).timestamp(1366057256324).consistency('ONE') # Multiple options
16
+ class InsertStatement < MutationStatement
17
+
18
+ # Specify names and values to insert.
19
+ #
20
+ # @param [Hash] values Hash of column values indexed by column name
21
+ def insert(values)
22
+ raise ArgumentError, "Cannot specify INSERT values twice" unless @values.nil?
23
+ @values = values
24
+ self
25
+ end
26
+
27
+ alias create insert
28
+
29
+ # Build a string representation of this CQL statement, suitable for execution by a CQL client.
30
+ # Do not validate the statement for completeness; Cassnadra will raise an error if a key
31
+ # component is missing.
32
+ #
33
+ # @return [String] a CQL INSERT statement with suitable constraints and options
34
+ def to_s
35
+ keys = @klass.primary_key.inject([]) { |h, k| h << [k, @values.delete(k)]; h }
36
+ if keys.any? { |k| k[1].nil? }
37
+ raise Cql::Model::MissingKey.new("Missing primary key(s) in INSERT statement: #{keys.select { |k| k[1].nil? }.map(&:first).map(&:inspect).join(', ')}")
38
+ end
39
+ s = "INSERT INTO #{@klass.table_name} (#{keys.map { |k| k[0] }.join(', ')}, #{@values.keys.join(', ')})"
40
+ s << " VALUES (#{keys.map { |k| ::Cql::Model::Query.cql_value(k[1]) }.join(', ')}, #{@values.values.map { |v| ::Cql::Model::Query.cql_value(v) }.join(', ')})"
41
+ options = []
42
+ options << "CONSISTENCY #{@consistency || @klass.write_consistency}"
43
+ options << "TIMESTAMP #{@timestamp}" unless @timestamp.nil?
44
+ options << "TTL #{@ttl}" unless @ttl.nil?
45
+ s << " USING #{options.join(' AND ')}"
46
+ s << ';'
47
+
48
+ s
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,48 @@
1
+ module Cql::Model::Query
2
+
3
+ # Common parent to InsertStatement and UpdateStatment
4
+ # provide helpers for managing common DSL settings
5
+ class MutationStatement < Statement
6
+
7
+ # Instantiate statement
8
+ #
9
+ # @param [Class] klass
10
+ # @param [Cql::Client] CQL client used to execute statement
11
+ def initialize(klass, client=nil)
12
+ super(klass, client)
13
+ @values = nil
14
+ @ttl = nil
15
+ @timestamp = nil
16
+ end
17
+
18
+ # DSL for setting TTL value
19
+ #
20
+ # @param [Fixnum] ttl_value TTL value in seconds
21
+ def ttl(ttl_value)
22
+ raise ArgumentError, "Cannot specify TTL twice" unless @ttl.nil?
23
+ @ttl = ttl_value
24
+ self
25
+ end
26
+
27
+ # DSL for setting timestamp value
28
+ #
29
+ # @param [Fixnum|String] timestamp_value (number of milliseconds since epoch or ISO 8601 date time value)
30
+ def timestamp(timestamp_value)
31
+ raise ArgumentError, "Cannot specify timestamp twice" unless @timestamp.nil?
32
+ @timestamp = timestamp_value
33
+ self
34
+ end
35
+
36
+ # Execute this statement on the CQL client connection
37
+ # INSERT statements do not return a result
38
+ #
39
+ # @return [true] always returns true
40
+ def execute
41
+ @client.execute(to_s)
42
+ true
43
+ end
44
+
45
+ end
46
+
47
+ end
48
+
@@ -0,0 +1,143 @@
1
+ module Cql::Model::Query
2
+
3
+ # SELECT statement DSL
4
+ # << A SELECT expression reads one or more records from a Cassandra column family and returns a result-set of rows.
5
+ # Each row consists of a row key and a collection of columns corresponding to the query. >>
6
+ # (from: http://www.datastax.com/docs/1.1/references/cql/SELECT)
7
+ #
8
+ # Ex:
9
+ # Model.select(:col1, :col2)
10
+ # Model.select(:col1, :col2).where { name == 'Joe' }
11
+ # Model.select(:col1, :col2).where { name == 'Joe' }.and { age.in(33,34,35) }
12
+ # Model.select(:col1, :col2).where { name == 'Joe' }.and { age.in(33,34,35) }.order_by(:age).desc
13
+ class SelectStatement < Statement
14
+
15
+ # Instantiate statement
16
+ #
17
+ # @param [Class] klass Model class
18
+ # @param [Cql::Client] CQL client used to execute statement
19
+ def initialize(klass, client=nil)
20
+ super(klass, client)
21
+ @columns = nil
22
+ @where = []
23
+ @order = ''
24
+ @limit = nil
25
+ end
26
+
27
+ # Create or append to the WHERE clause for this statement. The block that you pass will define the constraint
28
+ # and any where() parameters will be forwarded to the block as yield parameters. This allows late binding of
29
+ # variables in the WHERE clause, e.g. for prepared statements.
30
+ #
31
+ # @param [Object] *params parameters to be forwarded to the block
32
+ # @yield the block will be evaluated in the context of a ComparisonExpression to capture a CQL expression that will be appended to the WHERE clause
33
+ #
34
+ # @return [SelectStatement] always returns self
35
+ #
36
+ # @example find people named Joe
37
+ # where { name == "Joe" }
38
+ #
39
+ # @example find people older than 33 who are named Joe or Fred
40
+ # where { age > 33 }.and { name.in("Joe", "Fred") }
41
+ #
42
+ # @example find people older than 33
43
+ # where { age > 33 }
44
+ #
45
+ # @example find by a late-bound search term (e.g. for a named scope)
46
+ # where(age_chosen_by_user) { |chosen| age > chosen }
47
+ #
48
+ # @example find by a column name that is not a valid Ruby identifier
49
+ # where { timestamp(12345) == "logged in" }
50
+ #
51
+ # @see ComparisonExpression
52
+ # @see Cql::Model::ClassMethods#scope
53
+ def where(*params, &block)
54
+ @where << ComparisonExpression.new(*params, &block)
55
+ self
56
+ end
57
+
58
+ alias and where
59
+
60
+ # Specify the order in which result rows should be returned.
61
+ #
62
+ # @param [Array] *params glob of column names (symbols, strings, or Ruby values that correspond to valid column names)
63
+ # @return [SelectStatement] always returns self
64
+ def order(*columns)
65
+ raise ArgumentError, "Cannot specify ORDER BY twice" unless @order.empty?
66
+ @order = ::Cql::Model::Query.cql_column_names(columns)
67
+ self
68
+ end
69
+
70
+ alias order_by order
71
+
72
+ # @TODO docs
73
+ def asc
74
+ raise ArgumentError, "Cannot specify ASC / DESC twice" if @order =~ /ASC|DESC$/
75
+ raise ArgumentError, "Must specify ORDER BY before ASC" if @order.empty?
76
+ @order << " ASC"
77
+ self
78
+ end
79
+
80
+ # @TODO docs
81
+ def desc
82
+ raise ArgumentError, "Cannot specify ASC / DESC twice" if @order =~ /ASC|DESC$/
83
+ raise ArgumentError, "Must specify ORDER BY before DESC" if @order.empty?
84
+ @order << " DESC"
85
+ self
86
+ end
87
+
88
+ # @TODO docs
89
+ def limit(lim)
90
+ raise ArgumentError, "Cannot specify LIMIT twice" unless @limit.nil?
91
+ @limit = lim
92
+ self
93
+ end
94
+
95
+ # @TODO docs
96
+ def select(*columns)
97
+ raise ArgumentError, "Cannot specify SELECT column names twice" unless @columns.nil?
98
+ @columns = ::Cql::Model::Query.cql_column_names(columns)
99
+ self
100
+ end
101
+
102
+ # @return [String] a CQL SELECT statement with suitable constraints and options
103
+ def to_s
104
+ s = "SELECT #{@columns || '*'} FROM #{@klass.table_name}"
105
+
106
+ s << " USING CONSISTENCY " << (@consistency || @klass.write_consistency)
107
+
108
+ unless @where.empty?
109
+ s << " WHERE " << @where.map { |w| w.to_s }.join(' AND ')
110
+ end
111
+
112
+ s << " ORDER BY " << @order unless @order.empty?
113
+ s << " LIMIT #{@limit}" unless @limit.nil?
114
+ s << ';'
115
+ end
116
+
117
+ # Execute this SELECT statement on the CQL client connection and yield each row of the
118
+ # result set as a raw-data Hash.
119
+ #
120
+ # @yield each row of the result set
121
+ # @yieldparam [Hash] row a Ruby Hash representing the column names and values for a given row
122
+ # @return [true] always returns true
123
+ def execute(&block)
124
+ @client.execute(to_s).each_row(&block).size
125
+
126
+ true
127
+ end
128
+
129
+ alias each_row execute
130
+
131
+ # Execute this SELECT statement on the CQL client connection and yield each row of the
132
+ # as an instance of CQL::Model.
133
+ #
134
+ # @yield each row of the result set
135
+ # @yieldparam [Hash] row a Ruby Hash representing the column names and values for a given row
136
+ # @return [true] always returns true
137
+ def each(&block)
138
+ each_row do |row|
139
+ block.call(@klass.new(row))
140
+ end
141
+ end
142
+ end
143
+ end