whi-cassie 1.0.0

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