whi-cassie 1.0.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.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/HISTORY.txt +3 -0
- data/MIT-LICENSE +20 -0
- data/README.md +213 -0
- data/Rakefile +18 -0
- data/VERSION +1 -0
- data/lib/cassie/config.rb +62 -0
- data/lib/cassie/model.rb +483 -0
- data/lib/cassie/railtie.rb +20 -0
- data/lib/cassie/schema.rb +129 -0
- data/lib/cassie/testing.rb +46 -0
- data/lib/cassie.rb +317 -0
- data/lib/whi-cassie.rb +1 -0
- data/spec/cassie/config_spec.rb +56 -0
- data/spec/cassie/model_spec.rb +349 -0
- data/spec/cassie_spec.rb +147 -0
- data/spec/models/thing.rb +35 -0
- data/spec/models/type_tester.rb +23 -0
- data/spec/schema/test.cql +6 -0
- data/spec/spec_helper.rb +33 -0
- data/whi-cassie.gemspec +26 -0
- metadata +144 -0
data/lib/cassie/model.rb
ADDED
@@ -0,0 +1,483 @@
|
|
1
|
+
require 'active_model'
|
2
|
+
require 'active_support/hash_with_indifferent_access'
|
3
|
+
|
4
|
+
# This module provides a simple interface for models backed by Cassandra tables.
|
5
|
+
#
|
6
|
+
# Cassandra is very limited in how data can be accessed efficiently so this code
|
7
|
+
# is intentionally not designed as a full fledged DSL with all the nifty features
|
8
|
+
# of ActiveRecord. Doing so will only get you into trouble when you run into the
|
9
|
+
# limits of Cassandra data structures.
|
10
|
+
#
|
11
|
+
# It implements ActiveModel::Model and supports ActiveModel callbacks on :create,
|
12
|
+
# :update, :save, and :destroy as well as ActiveModel validations.
|
13
|
+
#
|
14
|
+
# Example:
|
15
|
+
#
|
16
|
+
# class Thing
|
17
|
+
# include Cassie::Model
|
18
|
+
#
|
19
|
+
# self.table_name = "things"
|
20
|
+
# self.keyspace = "test"
|
21
|
+
# self.primary_key = [:owner, :id]
|
22
|
+
#
|
23
|
+
# column :owner, :int
|
24
|
+
# column :id, :int, :as => :identifier
|
25
|
+
# column :val, :varchar, :as => :value
|
26
|
+
#
|
27
|
+
# ordering_key :id, :desc
|
28
|
+
#
|
29
|
+
# validates_presence_of :id, :value
|
30
|
+
#
|
31
|
+
# before_save do
|
32
|
+
# ...
|
33
|
+
# end
|
34
|
+
# end
|
35
|
+
module Cassie::Model
|
36
|
+
extend ActiveSupport::Concern
|
37
|
+
include ActiveModel::Model
|
38
|
+
include ActiveModel::Validations
|
39
|
+
include ActiveModel::Validations::Callbacks
|
40
|
+
extend ActiveModel::Callbacks
|
41
|
+
|
42
|
+
included do |base|
|
43
|
+
class_attribute :table_name, :instance_reader => false, :instance_writer => false
|
44
|
+
class_attribute :_keyspace, :instance_reader => false, :instance_writer => false
|
45
|
+
class_attribute :_primary_key, :instance_reader => false, :instance_writer => false
|
46
|
+
class_attribute :_columns, :instance_reader => false, :instance_writer => false
|
47
|
+
class_attribute :_column_aliases, :instance_reader => false, :instance_writer => false
|
48
|
+
class_attribute :_ordering_keys, :instance_reader => false, :instance_writer => false
|
49
|
+
define_model_callbacks :create, :update, :save, :destroy
|
50
|
+
self._columns = {}
|
51
|
+
self._column_aliases = HashWithIndifferentAccess.new
|
52
|
+
self._ordering_keys = {}
|
53
|
+
end
|
54
|
+
|
55
|
+
module ClassMethods
|
56
|
+
# Define a column name and type from the table. Columns must be defined in order
|
57
|
+
# to be used. This method will handle defining the getter and setter methods as well.
|
58
|
+
#
|
59
|
+
# The type specified must be a valid CQL data type.
|
60
|
+
#
|
61
|
+
# Because Cassandra stores column names with each row it is beneficial to use very short
|
62
|
+
# column names. You can specify the :as option to define a more human readable version.
|
63
|
+
# This will add the appropriate getter and setter methods as well as allow you to use
|
64
|
+
# the alias name in the methods that take an attributes hash.
|
65
|
+
#
|
66
|
+
# Defining a column will also define getter and setter methods for both the column name
|
67
|
+
# and the alias name (if specified). So `column :i, :int, as: :id` will define the methods
|
68
|
+
# `i`, `i=`, `id`, and `id=`.
|
69
|
+
def column(name, type, as: nil)
|
70
|
+
name = name.to_sym
|
71
|
+
type_class = nil
|
72
|
+
begin
|
73
|
+
type_class = "Cassandra::Types::#{type.to_s.downcase.classify}".constantize
|
74
|
+
rescue NameError
|
75
|
+
raise ArgumentError.new("#{type.inspect} is not an allowed Cassandra type")
|
76
|
+
end
|
77
|
+
|
78
|
+
self._columns = _columns.merge(name => type_class)
|
79
|
+
self._column_aliases = self._column_aliases.merge(name => name)
|
80
|
+
|
81
|
+
define_method("#{name}="){ |value| instance_variable_set(:"@#{name}", self.class.send(:coerce, value, type_class)) }
|
82
|
+
attr_reader name
|
83
|
+
if as && as.to_s != name.to_s
|
84
|
+
self._column_aliases = self._column_aliases.merge(as => name)
|
85
|
+
define_method(as){ send(name) }
|
86
|
+
define_method("#{as}="){|value| send("#{name}=", value) }
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Returns an array of the defined column names as symbols.
|
91
|
+
def column_names
|
92
|
+
_columns.keys
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns the internal column name after resolving any aliases.
|
96
|
+
def column_name(name_or_alias)
|
97
|
+
name = _column_aliases[name_or_alias] || name_or_alias
|
98
|
+
end
|
99
|
+
|
100
|
+
# Set the primary key for the table. The value should be set as an array with the
|
101
|
+
# clustering key first.
|
102
|
+
def primary_key=(value)
|
103
|
+
self._primary_key = Array(value).map { |column|
|
104
|
+
if column.is_a?(Array)
|
105
|
+
column.map(&:to_sym)
|
106
|
+
else
|
107
|
+
column.to_sym
|
108
|
+
end
|
109
|
+
}.flatten
|
110
|
+
end
|
111
|
+
|
112
|
+
# Return an array of column names for the table primary key.
|
113
|
+
def primary_key
|
114
|
+
_primary_key
|
115
|
+
end
|
116
|
+
|
117
|
+
# Define and ordering key for the table. The order attribute should be either :asc or :desc
|
118
|
+
def ordering_key(name, order)
|
119
|
+
order = order.to_sym
|
120
|
+
raise ArgumentError.new("order must be either :asc or :desc") unless order == :asc || order == :desc
|
121
|
+
_ordering_keys[name.to_sym] = order
|
122
|
+
end
|
123
|
+
|
124
|
+
# Set the keyspace for the table. The name should be an abstract keyspace name
|
125
|
+
# that is mapped to an actual keyspace name in the configuration. If the name
|
126
|
+
# provided is not mapped in the configuration, then the raw value will be used.
|
127
|
+
def keyspace=(name)
|
128
|
+
self._keyspace = name.to_s
|
129
|
+
end
|
130
|
+
|
131
|
+
# Return the keyspace name where the table is located.
|
132
|
+
def keyspace
|
133
|
+
connection.config.keyspace(_keyspace)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Return the full table name including the keyspace.
|
137
|
+
def full_table_name
|
138
|
+
if _keyspace
|
139
|
+
"#{keyspace}.#{table_name}"
|
140
|
+
else
|
141
|
+
table_name
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# Find all records.
|
146
|
+
#
|
147
|
+
# The +where+ argument can be a Hash, Array, or String WHERE clause to
|
148
|
+
# filter the rows returned. It is required so that you don't accidentally
|
149
|
+
# release code that returns all rows. If you really want to select all
|
150
|
+
# rows from a table you can specify the value :all.
|
151
|
+
#
|
152
|
+
# The +select+ argument can be used to limit which columns are returned and
|
153
|
+
# should be passed as an array of column names which can include aliases.
|
154
|
+
#
|
155
|
+
# The +order+ argument is a CQL fragment indicating the order. Note that
|
156
|
+
# Cassandra will only allow ordering by rows in the primary key.
|
157
|
+
#
|
158
|
+
# The +limit+ argument specifies how many rows to return.
|
159
|
+
#
|
160
|
+
# You can provide a block to this method in which case it will yield each
|
161
|
+
# record as it is foundto the block instead of returning them.
|
162
|
+
def find_all(where:, select: nil, order: nil, limit: nil, options: nil)
|
163
|
+
columns = (select ? Array(select).collect{|c| column_name(c)} : column_names)
|
164
|
+
cql = "SELECT #{columns.join(', ')} FROM #{full_table_name}"
|
165
|
+
values = nil
|
166
|
+
|
167
|
+
raise ArgumentError.new("Where clause cannot be blank. Pass :all to find all records.") if where.blank?
|
168
|
+
if where && where != :all
|
169
|
+
where_clause, values = cql_where_clause(where)
|
170
|
+
else
|
171
|
+
values = []
|
172
|
+
end
|
173
|
+
cql << " WHERE #{where_clause}" if where_clause
|
174
|
+
|
175
|
+
if order
|
176
|
+
cql << " ORDER BY #{order}"
|
177
|
+
end
|
178
|
+
|
179
|
+
if limit
|
180
|
+
cql << " LIMIT ?"
|
181
|
+
values << Integer(limit)
|
182
|
+
end
|
183
|
+
|
184
|
+
results = connection.find(cql, values, options)
|
185
|
+
records = [] unless block_given?
|
186
|
+
loop do
|
187
|
+
results.each do |row|
|
188
|
+
record = new(row)
|
189
|
+
record.instance_variable_set(:@persisted, true)
|
190
|
+
if block_given?
|
191
|
+
yield record
|
192
|
+
else
|
193
|
+
records << record
|
194
|
+
end
|
195
|
+
end
|
196
|
+
break if results.last_page?
|
197
|
+
results = results.next_page
|
198
|
+
end
|
199
|
+
records
|
200
|
+
end
|
201
|
+
|
202
|
+
# Find a single record that matches the +where+ argument.
|
203
|
+
def find(where)
|
204
|
+
options = nil
|
205
|
+
if where.is_a?(Hash) && where.include?(:options)
|
206
|
+
where = where.dup
|
207
|
+
options = where.delete(:options)
|
208
|
+
end
|
209
|
+
find_all(where: where, limit: 1, options: options).first
|
210
|
+
end
|
211
|
+
|
212
|
+
# Find a single record that matches the +where+ argument or raise an
|
213
|
+
# ActiveRecord::RecordNotFound error if none is found.
|
214
|
+
def find!(where)
|
215
|
+
record = find(where)
|
216
|
+
raise Cassie::RecordNotFound unless record
|
217
|
+
record
|
218
|
+
end
|
219
|
+
|
220
|
+
# Return the count of rows in the table. If the +where+ argument is specified
|
221
|
+
# then it will be added as the WHERE clause.
|
222
|
+
def count(where = nil)
|
223
|
+
options = nil
|
224
|
+
if where.is_a?(Hash) && where.include?(:options)
|
225
|
+
where = where.dup
|
226
|
+
options = where.delete(:options)
|
227
|
+
end
|
228
|
+
|
229
|
+
cql = "SELECT COUNT(*) FROM #{self.full_table_name}"
|
230
|
+
values = nil
|
231
|
+
|
232
|
+
if where
|
233
|
+
where_clause, values = cql_where_clause(where)
|
234
|
+
cql << " WHERE #{where_clause}"
|
235
|
+
else
|
236
|
+
where = connection.prepare(cql)
|
237
|
+
end
|
238
|
+
|
239
|
+
results = connection.find(cql, values, options)
|
240
|
+
results.rows.first["count"]
|
241
|
+
end
|
242
|
+
|
243
|
+
# Returns a newly created record. If the record is not valid then it won't be
|
244
|
+
# persisted.
|
245
|
+
def create(attributes)
|
246
|
+
record = new(attributes)
|
247
|
+
record.save
|
248
|
+
record
|
249
|
+
end
|
250
|
+
|
251
|
+
# Returns a newly created record or raises an ActiveRecord::RecordInvalid error
|
252
|
+
# if the record is not valid.
|
253
|
+
def create!(attributes)
|
254
|
+
record = new(attributes)
|
255
|
+
record.save!
|
256
|
+
record
|
257
|
+
end
|
258
|
+
|
259
|
+
# Delete all rows from the table that match the key hash. This method bypasses
|
260
|
+
# any destroy callbacks defined on the model.
|
261
|
+
def delete_all(key_hash)
|
262
|
+
cleanup_up_hash = {}
|
263
|
+
key_hash.each do |name, value|
|
264
|
+
cleanup_up_hash[column_name(name)] = value
|
265
|
+
end
|
266
|
+
connection.delete(full_table_name, cleanup_up_hash)
|
267
|
+
end
|
268
|
+
|
269
|
+
# All insert, update, and delete calls within the block will be sent as a single
|
270
|
+
# batch to Cassandra.
|
271
|
+
def batch
|
272
|
+
connection.batch do
|
273
|
+
yield
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
# Returns the Cassie instance used to communicate with Cassandra.
|
278
|
+
def connection
|
279
|
+
Cassie.instance
|
280
|
+
end
|
281
|
+
|
282
|
+
# Since Cassandra doesn't support offset we need to find the order key of record
|
283
|
+
# at the specified the offset.
|
284
|
+
#
|
285
|
+
# The key is a Hash describing the primary keys to search minus the last column defined
|
286
|
+
# for the primary key. This column is assumed to be an ordering key. If it isn't, this
|
287
|
+
# method will fail.
|
288
|
+
#
|
289
|
+
# The order argument can be used to specify an order for the ordering key (:asc or :desc).
|
290
|
+
# It will default to the natural order of the last ordering key as defined by the ordering_key method.
|
291
|
+
def offset_to_id(key, offset, order: nil, batch_size: 1000)
|
292
|
+
ordering_key = primary_key.last
|
293
|
+
cluster_order = _ordering_keys[ordering_key] || :asc
|
294
|
+
order ||= cluster_order
|
295
|
+
order_cql = "#{ordering_key} #{order}" unless order == cluster_order
|
296
|
+
|
297
|
+
from = nil
|
298
|
+
loop do
|
299
|
+
limit = (offset > batch_size ? batch_size : offset + 1)
|
300
|
+
conditions_cql = []
|
301
|
+
conditions = []
|
302
|
+
if from
|
303
|
+
conditions_cql << "#{ordering_key} #{order == :desc ? '<' : '>'} ?"
|
304
|
+
conditions << from
|
305
|
+
end
|
306
|
+
key.each do |name, value|
|
307
|
+
conditions_cql << "#{column_name(name)} = ?"
|
308
|
+
conditions << value
|
309
|
+
end
|
310
|
+
conditions.unshift(conditions_cql.join(" AND "))
|
311
|
+
|
312
|
+
results = find_all(:select => [ordering_key], :where => conditions, :limit => limit, :order => order_cql)
|
313
|
+
last_row = results.last if results.size == limit
|
314
|
+
last_id = last_row.send(ordering_key) if last_row
|
315
|
+
|
316
|
+
if last_id.nil?
|
317
|
+
return nil
|
318
|
+
elsif limit >= offset
|
319
|
+
return last_id
|
320
|
+
else
|
321
|
+
offset -= results.size
|
322
|
+
from = last_id
|
323
|
+
end
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
private
|
328
|
+
|
329
|
+
# Turn a hash of column value, array of [cql, value] or a CQL string into
|
330
|
+
# a CQL where clause. Returns the values pulled out in an array for making
|
331
|
+
# a prepared statement.
|
332
|
+
def cql_where_clause(where)
|
333
|
+
case where
|
334
|
+
when Hash
|
335
|
+
cql = []
|
336
|
+
values = []
|
337
|
+
where.each do |column, value|
|
338
|
+
col_name = column_name(column)
|
339
|
+
if value.is_a?(Array)
|
340
|
+
q = '?'
|
341
|
+
(value.size - 1).times{ q << ',?' }
|
342
|
+
cql << "#{col_name} IN (#{q})"
|
343
|
+
values.concat(value)
|
344
|
+
else
|
345
|
+
cql << "#{col_name} = ?"
|
346
|
+
values << coerce(value, _columns[col_name])
|
347
|
+
end
|
348
|
+
end
|
349
|
+
[cql.join(' AND '), values]
|
350
|
+
when Array
|
351
|
+
[where.first, where[1, where.size]]
|
352
|
+
when String
|
353
|
+
[where, []]
|
354
|
+
else
|
355
|
+
raise ArgumentError.new("invalid CQL where clause #{where}")
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
# Force a value to be the correct Cassandra data type.
|
360
|
+
def coerce(value, type_class)
|
361
|
+
if value.nil?
|
362
|
+
nil
|
363
|
+
elsif type_class == Cassandra::Types::Timeuuid && value.is_a?(Cassandra::TimeUuid)
|
364
|
+
value
|
365
|
+
elsif type_class == Cassandra::Types::Uuid
|
366
|
+
# Work around for bug in cassandra-driver 2.1.3
|
367
|
+
if value.is_a?(Cassandra::Uuid)
|
368
|
+
value
|
369
|
+
else
|
370
|
+
Cassandra::Uuid.new(value)
|
371
|
+
end
|
372
|
+
elsif type_class == Cassandra::Types::Timestamp && value.is_a?(String)
|
373
|
+
Time.parse(value)
|
374
|
+
elsif type_class == Cassandra::Types::Inet && value.is_a?(::IPAddr)
|
375
|
+
value
|
376
|
+
elsif type_class == Cassandra::Types::List
|
377
|
+
Array.new(value)
|
378
|
+
elsif type_class == Cassandra::Types::Set
|
379
|
+
Array.new(value).to_set
|
380
|
+
elsif type_class == Cassandra::Types::Map
|
381
|
+
Hash[value]
|
382
|
+
else
|
383
|
+
type_class.new(value)
|
384
|
+
end
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
def initialize(attributes = {})
|
389
|
+
super
|
390
|
+
@persisted = false
|
391
|
+
end
|
392
|
+
|
393
|
+
# Return true if the record has been persisted to Cassandra.
|
394
|
+
def persisted?
|
395
|
+
@persisted
|
396
|
+
end
|
397
|
+
|
398
|
+
# Save a record. Returns true if the record was persisted and false if it was invalid.
|
399
|
+
# This method will run the save callbacks as well as either the update or create
|
400
|
+
# callbacks as necessary.
|
401
|
+
def save(validate: true, ttl: nil)
|
402
|
+
valid_record = (validate ? valid? : true)
|
403
|
+
if valid_record
|
404
|
+
run_callbacks(:save) do
|
405
|
+
if persisted?
|
406
|
+
run_callbacks(:update) do
|
407
|
+
self.class.connection.update(self.class.full_table_name, values_hash, key_hash, :ttl => persistence_ttl || ttl)
|
408
|
+
end
|
409
|
+
else
|
410
|
+
run_callbacks(:create) do
|
411
|
+
self.class.connection.insert(self.class.full_table_name, attributes, :ttl => persistence_ttl || ttl)
|
412
|
+
@persisted = true
|
413
|
+
end
|
414
|
+
end
|
415
|
+
end
|
416
|
+
true
|
417
|
+
else
|
418
|
+
false
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
# Save a record. Returns true if the record was saved and raises an ActiveRecord::RecordInvalid
|
423
|
+
# error if the record is invalid.
|
424
|
+
def save!
|
425
|
+
if save
|
426
|
+
true
|
427
|
+
else
|
428
|
+
raise Cassie::RecordInvalid.new(self)
|
429
|
+
end
|
430
|
+
end
|
431
|
+
|
432
|
+
# Delete a record and call the destroy callbacks.
|
433
|
+
def destroy
|
434
|
+
run_callbacks(:destroy) do
|
435
|
+
self.class.connection.delete(self.class.full_table_name, key_hash)
|
436
|
+
@persisted = false
|
437
|
+
true
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
441
|
+
# Returns a hash of column to values. Column names will be symbols.
|
442
|
+
def attributes
|
443
|
+
hash = {}
|
444
|
+
self.class.column_names.each do |name|
|
445
|
+
hash[name] = send(name)
|
446
|
+
end
|
447
|
+
hash
|
448
|
+
end
|
449
|
+
|
450
|
+
# Subclasses can override this method to provide a TTL on the persisted record.
|
451
|
+
def persistence_ttl
|
452
|
+
nil
|
453
|
+
end
|
454
|
+
|
455
|
+
def eql?(other)
|
456
|
+
other.is_a?(self.class) && other.key_hash == key_hash
|
457
|
+
end
|
458
|
+
|
459
|
+
def ==(other)
|
460
|
+
eql?(other)
|
461
|
+
end
|
462
|
+
|
463
|
+
# Returns the primary key as a hash
|
464
|
+
def key_hash
|
465
|
+
hash = {}
|
466
|
+
self.class.primary_key.each do |key|
|
467
|
+
hash[key] = self.send(key)
|
468
|
+
end
|
469
|
+
hash
|
470
|
+
end
|
471
|
+
|
472
|
+
private
|
473
|
+
|
474
|
+
# Returns a hash of value except for the ones that constitute the primary key
|
475
|
+
def values_hash
|
476
|
+
pk = self.class.primary_key
|
477
|
+
hash = {}
|
478
|
+
self.class.column_names.each do |name|
|
479
|
+
hash[name] = send(name) unless pk.include?(name)
|
480
|
+
end
|
481
|
+
hash
|
482
|
+
end
|
483
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# Initialize Cassie instance with default behaviors for a Rails environment.
|
2
|
+
#
|
3
|
+
# Configuration will be gotten from config/cassie.yml.
|
4
|
+
#
|
5
|
+
# Schema location will be set to db/cassandra for development and test environments.
|
6
|
+
class Cassie::Railtie < Rails::Railtie
|
7
|
+
initializer "cassie.initialization" do
|
8
|
+
Cassie.logger = Rails.logger
|
9
|
+
|
10
|
+
config_file = Rails.root + 'config' + 'cassie.yml'
|
11
|
+
if config_file.exist?
|
12
|
+
options = YAML::load(ERB.new(config_file.read).result)[Rails.env]
|
13
|
+
if Rails.env.development? || Rails.env.test?
|
14
|
+
schema_dir = Rails.root + 'db' + 'cassandra'
|
15
|
+
options['schema_directory'] = schema_dir.to_s if schema_dir.exist?
|
16
|
+
end
|
17
|
+
Cassie.configure!(options)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
# This class can be used to create, drop, or get information about the cassandra schemas. This class
|
2
|
+
# is intended only to provide support for creating schemas in development and test environments. You
|
3
|
+
# should not use this class with your production environment since some of the methods can be destructive.
|
4
|
+
#
|
5
|
+
# The schemas are organized by keyspace.
|
6
|
+
#
|
7
|
+
# To load schemas for test and development environments you should specify a directory where the schema
|
8
|
+
# definition files live. The files should be named "#{abstract_keyspace}.cql". The actual keyspace name will
|
9
|
+
# be looked from the keyspace mapping in the configuration.
|
10
|
+
class Cassie::Schema
|
11
|
+
TABLES_CQL = "SELECT columnfamily_name FROM system.schema_columnfamilies WHERE keyspace_name = ?".freeze
|
12
|
+
|
13
|
+
CREATE_MATCHER = /\A(?<create>CREATE (TABLE|((CUSTOM )?INDEX)|TYPE|TRIGGER))(?<exist>( IF NOT EXISTS)?) (?<object>[a-z0-9_.]+)/i.freeze
|
14
|
+
DROP_MATCHER = /\A(?<drop>DROP (TABLE|INDEX|TYPE|TRIGGER))(?<exist>( IF EXISTS)?) (?<object>[a-z0-9_.]+)/i.freeze
|
15
|
+
|
16
|
+
attr_reader :keyspace
|
17
|
+
|
18
|
+
class << self
|
19
|
+
# Get all the defined schemas.
|
20
|
+
def all
|
21
|
+
schemas.values
|
22
|
+
end
|
23
|
+
|
24
|
+
# Find the schema for a keyspace using the abstract name.
|
25
|
+
def find(keyspace)
|
26
|
+
schemas[keyspace]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Throw out the cached schemas so they can be reloaded from the configuration.
|
30
|
+
def reset!
|
31
|
+
@schemas = nil
|
32
|
+
end
|
33
|
+
|
34
|
+
# Drop a specified keyspace by abstract name. The actual keyspace name will be looked up
|
35
|
+
# from the keyspaces in the configuration.
|
36
|
+
def drop!(keyspace_name)
|
37
|
+
keyspace = Cassie.instance.config.keyspace(keyspace_name)
|
38
|
+
raise ArgumentError.new("#{keyspace_name} is not defined as keyspace in the configuration") unless keyspace
|
39
|
+
|
40
|
+
drop_keyspace_cql = "DROP KEYSPACE IF EXISTS #{keyspace}"
|
41
|
+
Cassie.instance.execute(drop_keyspace_cql)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Load a specified keyspace by abstract name. The actual keyspace name will be looked up
|
45
|
+
# from the keyspaces in the configuration.
|
46
|
+
def load!(keyspace_name)
|
47
|
+
keyspace = Cassie.instance.config.keyspace(keyspace_name)
|
48
|
+
raise ArgumentError.new("#{keyspace_name} is not defined as keyspace in the configuration") unless keyspace
|
49
|
+
|
50
|
+
schema_file = File.join(Cassie.instance.config.schema_directory, "#{keyspace_name}.cql")
|
51
|
+
raise ArgumentError.new("#{keyspace_name} schema file does not exist at #{schema_file}") unless File.exist?(schema_file)
|
52
|
+
schema_statements = File.read(schema_file).split(';').collect{|s| s.strip.chomp(';')}
|
53
|
+
|
54
|
+
create_keyspace_cql = "CREATE KEYSPACE IF NOT EXISTS #{keyspace} WITH replication = {'class': 'SimpleStrategy', 'replication_factor' : 1}"
|
55
|
+
Cassie.instance.execute(create_keyspace_cql)
|
56
|
+
|
57
|
+
schema_statements.each do |statement|
|
58
|
+
statement = statement.gsub(/#(.*)$/, '').gsub(/\s+/, ' ').strip
|
59
|
+
create_match = statement.match(CREATE_MATCHER)
|
60
|
+
if create_match
|
61
|
+
object = create_match["object"]
|
62
|
+
object = "#{keyspace}.#{object}" unless object.include?('.')
|
63
|
+
statement = statement.sub(create_match.to_s, "#{create_match['create']} IF NOT EXISTS #{object}")
|
64
|
+
else
|
65
|
+
drop_match = statement.match(DROP_MATCHER)
|
66
|
+
if drop_match
|
67
|
+
object = drop_match["object"]
|
68
|
+
object = "#{keyspace}.#{object}" unless object.include?('.')
|
69
|
+
statement = statement.sub(drop_match.to_s, "#{drop_match['drop']} IF EXISTS #{object}")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
unless statement.blank?
|
73
|
+
Cassie.instance.execute(statement)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
|
79
|
+
# Drop all keyspaces defined in the configuration.
|
80
|
+
def drop_all!
|
81
|
+
Cassie.instance.config.keyspace_names.each do |keyspace|
|
82
|
+
drop!(keyspace)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Drop all keyspaces defined in the configuration.
|
87
|
+
def load_all!
|
88
|
+
Cassie.instance.config.keyspace_names.each do |keyspace|
|
89
|
+
load!(keyspace)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def schemas
|
96
|
+
unless defined?(@schemas) && @schemas
|
97
|
+
schemas = {}
|
98
|
+
Cassie.instance.config.keyspaces.each do |keyspace|
|
99
|
+
schemas[keyspace] = new(keyspace)
|
100
|
+
end
|
101
|
+
@schemas = schemas
|
102
|
+
end
|
103
|
+
@schemas
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def initialize(keyspace)
|
108
|
+
@keyspace = keyspace
|
109
|
+
end
|
110
|
+
|
111
|
+
# Returns a list of tables defined for the schema.
|
112
|
+
def tables
|
113
|
+
unless defined?(@tables) && @tables
|
114
|
+
tables = []
|
115
|
+
results = Cassie.instance.execute(TABLES_CQL, keyspace)
|
116
|
+
results.each do |row|
|
117
|
+
tables << row['columnfamily_name']
|
118
|
+
end
|
119
|
+
@tables = tables
|
120
|
+
end
|
121
|
+
@tables
|
122
|
+
end
|
123
|
+
|
124
|
+
# Truncate the data from a table.
|
125
|
+
def truncate!(table)
|
126
|
+
statement = Cassie.instance.prepare("TRUNCATE #{keyspace}.#{table}")
|
127
|
+
Cassie.instance.execute(statement)
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# This class provides helper methods for testing.
|
2
|
+
module Cassie::Testing
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
alias_method_chain :insert, :testing
|
7
|
+
end
|
8
|
+
|
9
|
+
class << self
|
10
|
+
# Prepare the test environment. This method must be called before running the test suite.
|
11
|
+
def prepare!
|
12
|
+
Cassie.send(:include, Cassie::Testing) unless Cassie.include?(Cassie::Testing)
|
13
|
+
Cassie::Schema.all.each do |schema|
|
14
|
+
schema.tables.each do |table|
|
15
|
+
schema.truncate!(table)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Wrap test cases as a block in this method. After the test case finishes, all tables
|
21
|
+
# that had data inserted into them will be truncated so that the data state will be clean
|
22
|
+
# for the next test case.
|
23
|
+
def cleanup!
|
24
|
+
begin
|
25
|
+
yield
|
26
|
+
ensure
|
27
|
+
if Thread.current[:cassie_inserted].present?
|
28
|
+
Cassie.instance.batch do
|
29
|
+
Thread.current[:cassie_inserted].each do |table|
|
30
|
+
keyspace, table = table.split('.', 2)
|
31
|
+
schema = Cassie::Schema.find(keyspace)
|
32
|
+
schema.truncate!(table) if schema
|
33
|
+
end
|
34
|
+
end
|
35
|
+
Thread.current[:cassie_inserted] = nil
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def insert_with_testing(table, *args)
|
42
|
+
Thread.current[:cassie_inserted] ||= Set.new
|
43
|
+
Thread.current[:cassie_inserted] << table
|
44
|
+
insert_without_testing(table, *args)
|
45
|
+
end
|
46
|
+
end
|