whi-cassie 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|