cql-model 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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