cequel 0.0.0 → 0.4.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.
- data/lib/cequel.rb +16 -0
- data/lib/cequel/batch.rb +58 -0
- data/lib/cequel/cql_row_specification.rb +22 -0
- data/lib/cequel/data_set.rb +346 -0
- data/lib/cequel/errors.rb +4 -0
- data/lib/cequel/keyspace.rb +106 -0
- data/lib/cequel/model.rb +95 -0
- data/lib/cequel/model/associations.rb +120 -0
- data/lib/cequel/model/callbacks.rb +32 -0
- data/lib/cequel/model/class_internals.rb +48 -0
- data/lib/cequel/model/column.rb +20 -0
- data/lib/cequel/model/dictionary.rb +202 -0
- data/lib/cequel/model/dirty.rb +53 -0
- data/lib/cequel/model/dynamic.rb +31 -0
- data/lib/cequel/model/errors.rb +13 -0
- data/lib/cequel/model/inheritable.rb +48 -0
- data/lib/cequel/model/instance_internals.rb +23 -0
- data/lib/cequel/model/local_association.rb +42 -0
- data/lib/cequel/model/magic.rb +79 -0
- data/lib/cequel/model/mass_assignment_security.rb +21 -0
- data/lib/cequel/model/naming.rb +17 -0
- data/lib/cequel/model/observer.rb +42 -0
- data/lib/cequel/model/persistence.rb +173 -0
- data/lib/cequel/model/properties.rb +143 -0
- data/lib/cequel/model/railtie.rb +33 -0
- data/lib/cequel/model/remote_association.rb +40 -0
- data/lib/cequel/model/scope.rb +362 -0
- data/lib/cequel/model/scoped.rb +50 -0
- data/lib/cequel/model/subclass_internals.rb +45 -0
- data/lib/cequel/model/timestamps.rb +52 -0
- data/lib/cequel/model/translation.rb +17 -0
- data/lib/cequel/model/validations.rb +50 -0
- data/lib/cequel/new_relic_instrumentation.rb +22 -0
- data/lib/cequel/row_specification.rb +63 -0
- data/lib/cequel/statement.rb +23 -0
- data/lib/cequel/version.rb +3 -0
- data/spec/environment.rb +3 -0
- data/spec/examples/data_set_spec.rb +382 -0
- data/spec/examples/keyspace_spec.rb +63 -0
- data/spec/examples/model/associations_spec.rb +109 -0
- data/spec/examples/model/callbacks_spec.rb +79 -0
- data/spec/examples/model/dictionary_spec.rb +413 -0
- data/spec/examples/model/dirty_spec.rb +39 -0
- data/spec/examples/model/dynamic_spec.rb +41 -0
- data/spec/examples/model/inheritable_spec.rb +45 -0
- data/spec/examples/model/magic_spec.rb +199 -0
- data/spec/examples/model/mass_assignment_security_spec.rb +13 -0
- data/spec/examples/model/naming_spec.rb +9 -0
- data/spec/examples/model/observer_spec.rb +86 -0
- data/spec/examples/model/persistence_spec.rb +201 -0
- data/spec/examples/model/properties_spec.rb +81 -0
- data/spec/examples/model/scope_spec.rb +677 -0
- data/spec/examples/model/serialization_spec.rb +20 -0
- data/spec/examples/model/spec_helper.rb +12 -0
- data/spec/examples/model/timestamps_spec.rb +52 -0
- data/spec/examples/model/translation_spec.rb +23 -0
- data/spec/examples/model/validations_spec.rb +86 -0
- data/spec/examples/spec_helper.rb +9 -0
- data/spec/models/asset.rb +21 -0
- data/spec/models/asset_observer.rb +5 -0
- data/spec/models/blog.rb +14 -0
- data/spec/models/blog_posts.rb +6 -0
- data/spec/models/category.rb +9 -0
- data/spec/models/comment.rb +12 -0
- data/spec/models/photo.rb +5 -0
- data/spec/models/post.rb +88 -0
- data/spec/models/post_comments.rb +14 -0
- data/spec/models/post_observer.rb +43 -0
- data/spec/support/helpers.rb +26 -0
- data/spec/support/result_stub.rb +27 -0
- metadata +125 -23
data/lib/cequel.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'active_support/core_ext'
|
2
|
+
require 'cassandra-cql'
|
3
|
+
|
4
|
+
require 'cequel/batch'
|
5
|
+
require 'cequel/errors'
|
6
|
+
require 'cequel/cql_row_specification'
|
7
|
+
require 'cequel/data_set'
|
8
|
+
require 'cequel/keyspace'
|
9
|
+
require 'cequel/row_specification'
|
10
|
+
require 'cequel/statement'
|
11
|
+
|
12
|
+
module Cequel
|
13
|
+
def self.connect(configuration = nil)
|
14
|
+
Keyspace.new(configuration || {})
|
15
|
+
end
|
16
|
+
end
|
data/lib/cequel/batch.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'stringio'
|
2
|
+
|
3
|
+
module Cequel
|
4
|
+
|
5
|
+
#
|
6
|
+
# Encapsulates a batch operation
|
7
|
+
#
|
8
|
+
# @see Keyspace::batch
|
9
|
+
#
|
10
|
+
class Batch
|
11
|
+
|
12
|
+
#
|
13
|
+
# @param keyspace [Keyspace] the keyspace that this batch will be executed on
|
14
|
+
# @param options [Hash]
|
15
|
+
# @option options (see Keyspace#batch)
|
16
|
+
# @see Keyspace#batch
|
17
|
+
# @todo support batch-level consistency options
|
18
|
+
#
|
19
|
+
def initialize(keyspace, options = {})
|
20
|
+
@keyspace = keyspace
|
21
|
+
@auto_apply = options[:auto_apply]
|
22
|
+
reset
|
23
|
+
end
|
24
|
+
|
25
|
+
#
|
26
|
+
# Add a statement to the batch.
|
27
|
+
#
|
28
|
+
# @param (see Keyspace#execute)
|
29
|
+
#
|
30
|
+
def execute(cql, *bind_vars)
|
31
|
+
@statement.append("#{cql}\n", *bind_vars)
|
32
|
+
@statement_count += 1
|
33
|
+
if @auto_apply && @statement_count >= @auto_apply
|
34
|
+
apply
|
35
|
+
reset
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
#
|
40
|
+
# Send the batch to Cassandra
|
41
|
+
#
|
42
|
+
def apply
|
43
|
+
return if @statement_count.zero?
|
44
|
+
@statement.append("APPLY BATCH\n")
|
45
|
+
@keyspace.execute(*@statement.args)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def reset
|
51
|
+
@statement = Statement.new
|
52
|
+
@statement.append("BEGIN BATCH\n")
|
53
|
+
@statement_count = 0
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Cequel
|
2
|
+
|
3
|
+
#
|
4
|
+
# @api private
|
5
|
+
#
|
6
|
+
class CqlRowSpecification
|
7
|
+
|
8
|
+
def self.build(condition, bind_vars)
|
9
|
+
[new(condition, bind_vars)]
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(condition, bind_vars)
|
13
|
+
@condition, @bind_vars = condition, bind_vars
|
14
|
+
end
|
15
|
+
|
16
|
+
def cql
|
17
|
+
[@condition, *@bind_vars]
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
@@ -0,0 +1,346 @@
|
|
1
|
+
module Cequel
|
2
|
+
|
3
|
+
#
|
4
|
+
# Encapsulates a data set, specified as a column family and optionally
|
5
|
+
# various query elements.
|
6
|
+
#
|
7
|
+
# @todo Support ALTER, CREATE, CREATE INDEX, DROP
|
8
|
+
#
|
9
|
+
class DataSet
|
10
|
+
|
11
|
+
include Enumerable
|
12
|
+
|
13
|
+
# @return [Keyspace] the keyspace this data set lives in
|
14
|
+
attr_reader :keyspace
|
15
|
+
|
16
|
+
# @return [Symbol] the name of the column family this data set draws from
|
17
|
+
attr_reader :column_family
|
18
|
+
|
19
|
+
#
|
20
|
+
# @param column_family [Symbol] column family for this data set
|
21
|
+
# @param keyspace [Keyspace] keyspace this data set's column family lives in
|
22
|
+
#
|
23
|
+
# @see Keyspace#[]
|
24
|
+
#
|
25
|
+
def initialize(column_family, keyspace)
|
26
|
+
@column_family, @keyspace = column_family, keyspace
|
27
|
+
@select_columns, @select_options, @row_specifications = [], {}, []
|
28
|
+
end
|
29
|
+
|
30
|
+
#
|
31
|
+
# Insert a row into the column family.
|
32
|
+
#
|
33
|
+
# @param [Hash] data column-value pairs. The first entry *must* be the key column.
|
34
|
+
# @param [Options] options options for persisting the row
|
35
|
+
# @option (see #generate_upsert_options)
|
36
|
+
# @note if a enclosed in a Keyspace#batch block, this method will be executed as part of the batch.
|
37
|
+
#
|
38
|
+
def insert(data, options = {})
|
39
|
+
options.symbolize_keys!
|
40
|
+
cql = "INSERT INTO #{@column_family}" <<
|
41
|
+
" (?) VALUES (?)" <<
|
42
|
+
generate_upsert_options(options)
|
43
|
+
|
44
|
+
@keyspace.write(cql, data.keys, data.values)
|
45
|
+
end
|
46
|
+
|
47
|
+
#
|
48
|
+
# Update rows
|
49
|
+
#
|
50
|
+
# @param [Hash] data column-value pairs
|
51
|
+
# @param [Options] options options for persisting the column data
|
52
|
+
# @option (see #generate_upsert_options)
|
53
|
+
# @note if a enclosed in a Keyspace#batch block, this method will be executed as part of the batch.
|
54
|
+
#
|
55
|
+
# @todo support counter columns
|
56
|
+
#
|
57
|
+
def update(data, options = {})
|
58
|
+
statement = Statement.new.
|
59
|
+
append("UPDATE #{@column_family}").
|
60
|
+
append(generate_upsert_options(options)).
|
61
|
+
append(" SET " << data.keys.map { |k| "? = ?" }.join(', '), *data.to_a.flatten).
|
62
|
+
append(*row_specifications_cql)
|
63
|
+
|
64
|
+
@keyspace.write(*statement.args)
|
65
|
+
rescue EmptySubquery
|
66
|
+
# Noop -- no rows to update
|
67
|
+
end
|
68
|
+
|
69
|
+
#
|
70
|
+
# Delete data from the column family
|
71
|
+
#
|
72
|
+
# @param columns zero or more columns to delete. Deletes the entire row if none specified.
|
73
|
+
# @param options persistence options
|
74
|
+
# @note if a enclosed in a Keyspace#batch block, this method will be executed as part of the batch.
|
75
|
+
#
|
76
|
+
def delete(*columns)
|
77
|
+
options = columns.extract_options!
|
78
|
+
column_aliases = columns.empty? ? '' : " #{columns.join(', ')}"
|
79
|
+
statement = Statement.new.append('DELETE')
|
80
|
+
statement = statement.append(' ?', columns) if columns.any?
|
81
|
+
statement = statement.
|
82
|
+
append(" FROM #{@column_family}").
|
83
|
+
append(generate_upsert_options(options)).
|
84
|
+
append(*row_specifications_cql)
|
85
|
+
|
86
|
+
@keyspace.write(*statement.args)
|
87
|
+
rescue EmptySubquery
|
88
|
+
# Noop -- no rows to delete
|
89
|
+
end
|
90
|
+
|
91
|
+
#
|
92
|
+
# Remove all data from the column family.
|
93
|
+
#
|
94
|
+
# @note This method always executes immediately, even if called within a batch block. This method does not respect scoped row specifications.
|
95
|
+
# @see #delete
|
96
|
+
#
|
97
|
+
def truncate
|
98
|
+
@keyspace.execute("TRUNCATE #{@column_family}")
|
99
|
+
end
|
100
|
+
|
101
|
+
#
|
102
|
+
# Select specified columns from this data set.
|
103
|
+
#
|
104
|
+
# @param *columns [Symbol,Array] columns to select
|
105
|
+
# @return [DataSet] new data set scoped to specified columns
|
106
|
+
#
|
107
|
+
def select(*columns)
|
108
|
+
options = columns.extract_options!.symbolize_keys
|
109
|
+
clone.tap do |data_set|
|
110
|
+
if columns.length == 1 && Range === columns.first
|
111
|
+
range = columns.first
|
112
|
+
options[:from] = range.first
|
113
|
+
options[:to] = range.last
|
114
|
+
else
|
115
|
+
data_set.select_columns.concat(columns.flatten)
|
116
|
+
end
|
117
|
+
data_set.select_options.merge!(options)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
#
|
122
|
+
# Select specified columns from this data set, overriding chained scope.
|
123
|
+
#
|
124
|
+
# @param *columns [Symbol,Array] columns to select
|
125
|
+
# @return [DataSet] new data set scoped to specified columns
|
126
|
+
#
|
127
|
+
def select!(*columns)
|
128
|
+
clone.tap do |data_set|
|
129
|
+
data_set.select_columns.replace(columns.flatten)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
#
|
134
|
+
# Add consistency option for data set retrieval
|
135
|
+
#
|
136
|
+
# @param consistency [:one,:quorum,:local_quorum,:each_quorum]
|
137
|
+
# @return [DataSet] new data set with specified consistency
|
138
|
+
#
|
139
|
+
def consistency(consistency)
|
140
|
+
clone.tap { |data_set| data_set.consistency = consistency.to_sym }
|
141
|
+
end
|
142
|
+
|
143
|
+
#
|
144
|
+
# Add a row_specification to this data set
|
145
|
+
#
|
146
|
+
# @param row_specification [Hash, String] row_specification statement
|
147
|
+
# @param *bind_vars bind variables, only if using a CQL string row_specification
|
148
|
+
# @return [DataSet] new data set scoped to this row_specification
|
149
|
+
# @example Using a simple hash
|
150
|
+
# DB[:posts].where(:title => 'Hey')
|
151
|
+
# @example Using a CQL string
|
152
|
+
# DB[:posts].where("title = 'Hey'")
|
153
|
+
# @example Using a CQL string with bind variables
|
154
|
+
# DB[:posts].where('title = ?', 'Hey')
|
155
|
+
# @example Use another data set as an input -- inner data set must return a single column per row!
|
156
|
+
# DB[:blogs].where(:id => DB[:posts].select(:blog_id).where(:title => 'Hey'))
|
157
|
+
#
|
158
|
+
def where(row_specification, *bind_vars)
|
159
|
+
clone.tap do |data_set|
|
160
|
+
data_set.row_specifications.
|
161
|
+
concat(build_row_specifications(row_specification, bind_vars))
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def where!(row_specification, *bind_vars)
|
166
|
+
clone.tap do |data_set|
|
167
|
+
data_set.row_specifications.
|
168
|
+
replace(build_row_specifications(row_specification, bind_vars))
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
#
|
173
|
+
# Limit the number of rows returned by this data set
|
174
|
+
#
|
175
|
+
# @param limit [Integer] maximum number of rows to return
|
176
|
+
# @return [DataSet] new data set scoped with given limit
|
177
|
+
#
|
178
|
+
def limit(limit)
|
179
|
+
clone.tap { |data_set| data_set.limit = limit }
|
180
|
+
end
|
181
|
+
|
182
|
+
#
|
183
|
+
# Enumerate over rows in this data set. Along with #each, all other
|
184
|
+
# Enumerable methods are implemented.
|
185
|
+
#
|
186
|
+
# @yield [Hash] result rows
|
187
|
+
# @return [Enumerator] enumerator for rows, if no block given
|
188
|
+
#
|
189
|
+
def each
|
190
|
+
if block_given?
|
191
|
+
begin
|
192
|
+
@keyspace.execute(*cql).fetch do |row|
|
193
|
+
yield row.to_hash.with_indifferent_access
|
194
|
+
end
|
195
|
+
rescue EmptySubquery
|
196
|
+
# Noop -- yield no results
|
197
|
+
end
|
198
|
+
else
|
199
|
+
enum_for(:each)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
#
|
204
|
+
# @return [Hash] the first row in this data set
|
205
|
+
#
|
206
|
+
def first
|
207
|
+
row = @keyspace.execute(*limit(1).cql).fetch_row
|
208
|
+
row.to_hash.with_indifferent_access if row
|
209
|
+
rescue EmptySubquery
|
210
|
+
nil
|
211
|
+
end
|
212
|
+
|
213
|
+
#
|
214
|
+
# @return [Fixnum] the number of rows in this data set
|
215
|
+
#
|
216
|
+
def count
|
217
|
+
@keyspace.execute(*count_cql).fetch_row['count']
|
218
|
+
rescue EmptySubquery
|
219
|
+
0
|
220
|
+
end
|
221
|
+
|
222
|
+
#
|
223
|
+
# @raise [EmptySubquery] if row specifications use a subquery that returns no results
|
224
|
+
# @return [String] CQL select statement encoding this data set's scope.
|
225
|
+
#
|
226
|
+
def cql
|
227
|
+
statement = Statement.new.
|
228
|
+
append(*select_cql).
|
229
|
+
append(" FROM #{@column_family}").
|
230
|
+
append(consistency_cql).
|
231
|
+
append(*row_specifications_cql).
|
232
|
+
append(limit_cql).
|
233
|
+
args
|
234
|
+
end
|
235
|
+
|
236
|
+
#
|
237
|
+
# @return [String] CQL statement to get count of rows in this data set
|
238
|
+
#
|
239
|
+
def count_cql
|
240
|
+
Statement.new.
|
241
|
+
append("SELECT COUNT(*) FROM #{@column_family}").
|
242
|
+
append(consistency_cql).
|
243
|
+
append(*row_specifications_cql).
|
244
|
+
append(limit_cql).args
|
245
|
+
end
|
246
|
+
|
247
|
+
def inspect
|
248
|
+
"#<#{self.class.name}: #{CassandraCQL::Statement.sanitize(cql.first, cql[1..-1])}>"
|
249
|
+
end
|
250
|
+
|
251
|
+
def ==(other)
|
252
|
+
cql == other.cql
|
253
|
+
end
|
254
|
+
|
255
|
+
attr_reader :select_columns, :select_options, :row_specifications
|
256
|
+
attr_writer :consistency, :limit
|
257
|
+
|
258
|
+
private
|
259
|
+
|
260
|
+
def initialize_copy(source)
|
261
|
+
super
|
262
|
+
@select_columns = source.select_columns.clone
|
263
|
+
@select_options = source.select_options.clone
|
264
|
+
@row_specifications = source.row_specifications.clone
|
265
|
+
end
|
266
|
+
|
267
|
+
#
|
268
|
+
# Generate CQL option statement for inserts and updates
|
269
|
+
#
|
270
|
+
# @param [Hash] options options for insert
|
271
|
+
# @option options [Symbol,String] :consistency required consistency for the write
|
272
|
+
# @option options [Integer] :ttl time-to-live in seconds for the written data
|
273
|
+
# @option options [Time,Integer] :timestamp the timestamp associated with the column values
|
274
|
+
#
|
275
|
+
def generate_upsert_options(options)
|
276
|
+
if options.empty?
|
277
|
+
''
|
278
|
+
else
|
279
|
+
' USING ' <<
|
280
|
+
options.map do |key, value|
|
281
|
+
serialized_value =
|
282
|
+
case key
|
283
|
+
when :consistency then value.to_s.upcase
|
284
|
+
when :timestamp then value.to_i
|
285
|
+
else value
|
286
|
+
end
|
287
|
+
"#{key.to_s.upcase} #{serialized_value}"
|
288
|
+
end.join(' AND ')
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
def select_cql
|
293
|
+
['SELECT '].tap do |args|
|
294
|
+
cql = args.first
|
295
|
+
if @select_options[:first]
|
296
|
+
cql << "FIRST #{@select_options[:first]} "
|
297
|
+
elsif @select_options[:last]
|
298
|
+
cql << "FIRST #{@select_options[:last]} REVERSED "
|
299
|
+
end
|
300
|
+
if @select_options[:from] || @select_options[:to]
|
301
|
+
cql << '?..?'
|
302
|
+
args << (@select_options[:from] || '') << (@select_options[:to] || '')
|
303
|
+
elsif @select_columns.any?
|
304
|
+
cql << '?'
|
305
|
+
args << @select_columns
|
306
|
+
else
|
307
|
+
cql << '*'
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
def consistency_cql
|
313
|
+
if @consistency
|
314
|
+
" USING CONSISTENCY #{@consistency.upcase}"
|
315
|
+
else ''
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
def row_specifications_cql
|
320
|
+
if @row_specifications.any?
|
321
|
+
cql_fragments, bind_vars = [], []
|
322
|
+
@row_specifications.each do |spec|
|
323
|
+
cql_with_vars = spec.cql
|
324
|
+
cql_fragments << cql_with_vars.shift
|
325
|
+
bind_vars.concat(cql_with_vars)
|
326
|
+
end
|
327
|
+
[" WHERE #{cql_fragments.join(' AND ')}", *bind_vars]
|
328
|
+
else ['']
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
def limit_cql
|
333
|
+
@limit ? " LIMIT #{@limit}" : ''
|
334
|
+
end
|
335
|
+
|
336
|
+
def build_row_specifications(row_specification, bind_vars)
|
337
|
+
case row_specification
|
338
|
+
when Hash then RowSpecification.build(row_specification)
|
339
|
+
when String then CqlRowSpecification.build(row_specification, bind_vars)
|
340
|
+
else raise ArgumentError, "Invalid argument #{row_specification.inspect}; expected Hash or String"
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
end
|
345
|
+
|
346
|
+
end
|