cassandra_store 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.
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "lib"
6
+ t.pattern = "test/**/*_test.rb"
7
+ t.verbose = true
8
+ end
@@ -0,0 +1,31 @@
1
+ lib = File.expand_path("lib", __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "cassandra_store/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "cassandra_store"
7
+ spec.version = CassandraStore::VERSION
8
+ spec.authors = ["Benjamin Vetter"]
9
+ spec.email = ["vetter@flakks.com"]
10
+ spec.description = %q{Powerful ORM for Cassandra}
11
+ spec.summary = %q{Easy to use ActiveRecord like ORM for Cassandra}
12
+ spec.homepage = "https://github.com/mrkamel/cassandra_store"
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files`.split($/)
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_development_dependency "bundler"
21
+ spec.add_development_dependency "minitest"
22
+ spec.add_development_dependency "rake"
23
+ spec.add_development_dependency "rspec"
24
+ spec.add_development_dependency "rubocop"
25
+
26
+ spec.add_dependency "activemodel", ">= 3.0"
27
+ spec.add_dependency "activesupport", ">= 3.0"
28
+ spec.add_dependency "cassandra-driver"
29
+ spec.add_dependency "connection_pool"
30
+ spec.add_dependency "hooks"
31
+ end
@@ -0,0 +1,9 @@
1
+ cassandra:
2
+ image: cassandra:3.11
3
+ environment:
4
+ - MAX_HEAP_SIZE=512m
5
+ - HEAP_NEWSIZE=128m
6
+ - KEYSPACES=cassandra_store
7
+ ports:
8
+ - 127.0.0.1:9042:9042
9
+
@@ -0,0 +1,18 @@
1
+ require "cassandra"
2
+ require "connection_pool"
3
+ require "active_model"
4
+ require "active_support/all"
5
+ require "hooks"
6
+
7
+ require "cassandra_store/version"
8
+ require "cassandra_store/base"
9
+ require "cassandra_store/relation"
10
+ require "cassandra_store/schema_migration"
11
+ require "cassandra_store/migration"
12
+ require "cassandra_store/railtie" if defined?(Rails)
13
+
14
+ module CassandraStore
15
+ class RecordInvalid < StandardError; end
16
+ class RecordNotPersisted < StandardError; end
17
+ class UnknownType < StandardError; end
18
+ end
@@ -0,0 +1,426 @@
1
+ class CassandraStore::Base
2
+ include ActiveModel::Dirty
3
+ include ActiveModel::Validations
4
+ include Hooks
5
+
6
+ class_attribute :keyspace_settings
7
+ class_attribute :cluster_pool
8
+ class_attribute :connection_pool
9
+
10
+ class_attribute :logger
11
+ self.logger = Logger.new(STDOUT)
12
+ logger.level = Logger::INFO
13
+
14
+ class_attribute :columns
15
+ self.columns = {}
16
+
17
+ define_hooks :before_validation, :after_validation
18
+ define_hooks :before_create, :after_create
19
+ define_hooks :before_update, :after_update
20
+ define_hooks :before_save, :after_save
21
+ define_hooks :before_destroy, :after_destroy
22
+
23
+ def self.configure(hosts: ["127.0.0.1"], keyspace:, cluster_settings: {}, replication: {}, durable_writes: true, pool: { size: 5, timeout: 5 })
24
+ self.keyspace_settings = { name: keyspace, replication: replication, durable_writes: durable_writes }
25
+
26
+ self.cluster_pool = ConnectionPool.new(pool) do
27
+ Cassandra.cluster(cluster_settings.merge(hosts: hosts)).connect
28
+ end
29
+
30
+ self.connection_pool = ConnectionPool.new(pool) do
31
+ Cassandra.cluster(cluster_settings.merge(hosts: hosts)).connect(keyspace)
32
+ end
33
+ end
34
+
35
+ def self.drop_keyspace(name: keyspace_settings[:name], if_exists: false)
36
+ cluster_execute("DROP KEYSPACE #{if_exists ? "IF EXISTS" : ""} #{quote_keyspace_name(name)}")
37
+ end
38
+
39
+ def self.create_keyspace(name: keyspace_settings[:name], replication: keyspace_settings[:replication], durable_writes: keyspace_settings[:durable_writes], if_not_exists: false)
40
+ cql = <<~CQL
41
+ CREATE KEYSPACE #{if_not_exists ? "IF NOT EXISTS" : ""} #{quote_keyspace_name(name)}
42
+ WITH REPLICATION = {
43
+ #{replication.map { |key, value| "#{quote_value(key)}: #{quote_value(value)}" }.join(", ")}
44
+ }
45
+ AND DURABLE_WRITES = #{quote_value(durable_writes)}
46
+ CQL
47
+
48
+ cluster_execute(cql)
49
+ end
50
+
51
+ def initialize(attributes = {})
52
+ @persisted = false
53
+ @destroyed = false
54
+
55
+ assign(attributes)
56
+ end
57
+
58
+ def ==(other)
59
+ other.instance_of?(self.class) && key_values == other.key_values
60
+ end
61
+
62
+ def eql?(other)
63
+ self == other
64
+ end
65
+
66
+ def hash
67
+ key_values.hash
68
+ end
69
+
70
+ def key_values
71
+ self.class.key_columns.map { |column, _| read_raw_attribute(column) }
72
+ end
73
+
74
+ def assign(attributes = {})
75
+ attributes.each do |column, value|
76
+ send(:"#{column}=", value)
77
+ end
78
+ end
79
+
80
+ def attributes
81
+ columns.each_with_object({}) do |(name, _), hash|
82
+ hash[name] = read_raw_attribute(name)
83
+ end
84
+ end
85
+
86
+ def read_raw_attribute(attribute)
87
+ return nil unless instance_variable_defined?(:"@#{attribute}")
88
+
89
+ instance_variable_get(:"@#{attribute}")
90
+ end
91
+
92
+ def write_raw_attribute(attribute, value)
93
+ instance_variable_set(:"@#{attribute}", value)
94
+ end
95
+
96
+ def self.create!(attributes = {})
97
+ new(attributes).tap(&:save!)
98
+ end
99
+
100
+ def self.create(attributes = {})
101
+ new(attributes).tap(&:save)
102
+ end
103
+
104
+ def save!
105
+ validate!
106
+
107
+ _save
108
+ end
109
+
110
+ def save
111
+ return false unless valid?
112
+
113
+ _save
114
+ end
115
+
116
+ def valid?(context = nil)
117
+ context ||= new_record? ? :create : :update
118
+
119
+ run_hook :before_validation
120
+
121
+ retval = super(context)
122
+
123
+ run_hook :after_validation
124
+
125
+ retval
126
+ end
127
+
128
+ def validate!(context = nil)
129
+ valid?(context) || raise(CassandraStore::RecordInvalid, errors.to_a.join(", "))
130
+ end
131
+
132
+ def persisted?
133
+ !!@persisted
134
+ end
135
+
136
+ def persisted!
137
+ @persisted = true
138
+ end
139
+
140
+ def new_record?
141
+ !persisted?
142
+ end
143
+
144
+ def destroyed?
145
+ !!@destroyed
146
+ end
147
+
148
+ def destroyed!
149
+ @destroyed = true
150
+ end
151
+
152
+ def update(attributes = {})
153
+ assign(attributes)
154
+
155
+ save
156
+ end
157
+
158
+ def update!(attributes = {})
159
+ assign(attributes)
160
+
161
+ save!
162
+ end
163
+
164
+ def destroy
165
+ raise CassandraStore::RecordNotPersisted unless persisted?
166
+
167
+ run_hook :before_destroy
168
+
169
+ delete
170
+
171
+ destroyed!
172
+
173
+ run_hook :after_destroy
174
+
175
+ true
176
+ end
177
+
178
+ def delete
179
+ raise CassandraStore::RecordNotPersisted unless persisted?
180
+
181
+ self.class.execute(delete_record_statement)
182
+
183
+ true
184
+ end
185
+
186
+ def self.table_name
187
+ name.tableize
188
+ end
189
+
190
+ def self.key_columns
191
+ partition_key_columns.merge(clustering_key_columns)
192
+ end
193
+
194
+ def self.partition_key_columns
195
+ columns.select { |_, options| options[:partition_key] }
196
+ end
197
+
198
+ def self.clustering_key_columns
199
+ columns.select { |_, options| options[:clustering_key] }
200
+ end
201
+
202
+ def self.column(name, type, partition_key: false, clustering_key: false)
203
+ self.columns = columns.merge(name => { type: type, partition_key: partition_key, clustering_key: clustering_key })
204
+
205
+ define_attribute_methods name
206
+
207
+ define_method name do
208
+ read_raw_attribute(name)
209
+ end
210
+
211
+ define_method :"#{name}=" do |value|
212
+ raise(ArgumentError, "Can't update key '#{name}' for persisted records") if persisted? && (self.class.columns[name][:partition_key] || self.class.columns[name][:clustering_key])
213
+
214
+ send :"#{name}_will_change!" unless read_raw_attribute(name) == value
215
+
216
+ write_raw_attribute(name, self.class.cast_value(value, type))
217
+ end
218
+ end
219
+
220
+ def self.relation
221
+ CassandraStore::Relation.new(target: self)
222
+ end
223
+
224
+ class << self
225
+ delegate :all, :where, :where_cql, :count, :limit, :first, :order, :distinct, :select, :find_each, :find_in_batches, :delete_in_batches, to: :relation
226
+ end
227
+
228
+ def self.cast_value(value, type)
229
+ return nil if value.nil?
230
+
231
+ case type
232
+ when :text
233
+ value.to_s
234
+ when :int, :bigint
235
+ Integer(value)
236
+ when :boolean
237
+ return true if [1, "1", "true", true].include?(value)
238
+ return false if [0, "0", "false", false].include?(value)
239
+
240
+ raise ArgumentError, "Can't cast '#{value}' to #{type}"
241
+ when :date
242
+ if value.is_a?(String) then Date.parse(value)
243
+ elsif value.respond_to?(:to_date) then value.to_date
244
+ else raise(ArgumentError, "Can't cast '#{value}' to #{type}")
245
+ end
246
+ when :timestamp
247
+ if value.is_a?(String) then Time.parse(value)
248
+ elsif value.respond_to?(:to_time) then value.to_time
249
+ elsif value.is_a?(Numeric) then Time.at(value)
250
+ else raise(ArgumentError, "Can't cast '#{value}' to #{type}")
251
+ end.utc.round(3)
252
+ when :timeuuid
253
+ return value if value.is_a?(Cassandra::TimeUuid)
254
+ return Cassandra::TimeUuid.new(value) if value.is_a?(String) || value.is_a?(Integer)
255
+
256
+ raise ArgumentError, "Can't cast '#{value}' to #{type}"
257
+ when :uuid
258
+ return value if value.is_a?(Cassandra::Uuid)
259
+ return Cassandra::Uuid.new(value) if value.is_a?(String) || value.is_a?(Integer)
260
+
261
+ raise ArgumentError, "Can't cast '#{value}' to #{type}"
262
+ else
263
+ raise CassandraStore::UnknownType, "Unknown type #{type}"
264
+ end
265
+ end
266
+
267
+ def self.quote_keyspace_name(keyspace_name)
268
+ quote_column_name(keyspace_name)
269
+ end
270
+
271
+ def self.quote_table_name(table_name)
272
+ quote_column_name(table_name)
273
+ end
274
+
275
+ def self.quote_column_name(column_name)
276
+ raise(ArgumentError, "Invalid column name #{column_name}") if column_name.to_s.include?("\"")
277
+
278
+ "\"#{column_name}\""
279
+ end
280
+
281
+ def self.quote_value(value)
282
+ case value
283
+ when Time, ActiveSupport::TimeWithZone
284
+ (value.to_r * 1000).round.to_s
285
+ when DateTime
286
+ quote_value(value.utc.to_time)
287
+ when Date
288
+ quote_value(value.strftime("%Y-%m-%d"))
289
+ when Numeric, true, false, Cassandra::Uuid
290
+ value.to_s
291
+ else
292
+ quote_string(value.to_s)
293
+ end
294
+ end
295
+
296
+ def self.quote_string(string)
297
+ "'#{string.gsub("'", "''")}'"
298
+ end
299
+
300
+ def self.truncate_table
301
+ execute "TRUNCATE TABLE #{quote_table_name table_name}"
302
+ end
303
+
304
+ def self.cluster_execute(statement, options = {})
305
+ logger.debug(statement)
306
+
307
+ cluster_pool.with do |connection|
308
+ connection.execute(statement, options)
309
+ end
310
+ end
311
+
312
+ def self.execute(statement, options = {})
313
+ logger.debug(statement)
314
+
315
+ connection_pool.with do |connection|
316
+ connection.execute(statement, options)
317
+ end
318
+ end
319
+
320
+ def self.execute_batch(statements, options = {})
321
+ statements.each do |statement|
322
+ logger.debug(statement)
323
+ end
324
+
325
+ connection_pool.with do |connection|
326
+ batch = connection.send(:"#{options[:batch_type] || "logged"}_batch")
327
+
328
+ statements.each do |statement|
329
+ batch.add(statement)
330
+ end
331
+
332
+ connection.execute(batch, options.except(:batch_type))
333
+ end
334
+ end
335
+
336
+ def self.statement(template, args = {})
337
+ res = template.dup
338
+
339
+ args.each do |key, value|
340
+ res.gsub!(":#{key}", quote_value(value))
341
+ end
342
+
343
+ res
344
+ end
345
+
346
+ private
347
+
348
+ def _save
349
+ run_hook :before_save
350
+
351
+ if persisted?
352
+ update_record
353
+ else
354
+ create_record
355
+ persisted!
356
+ end
357
+
358
+ run_hook :after_save
359
+
360
+ changes_applied
361
+
362
+ true
363
+ end
364
+
365
+ def create_record
366
+ run_hook :before_create
367
+
368
+ self.class.execute(create_record_statement)
369
+
370
+ run_hook :after_create
371
+ end
372
+
373
+ def create_record_statement
374
+ columns_clause = changes.keys.map { |column_name| self.class.quote_column_name column_name }.join(", ")
375
+ values_clause = changes.values.map(&:last).map { |value| self.class.quote_value value }.join(", ")
376
+
377
+ "INSERT INTO #{self.class.quote_table_name self.class.table_name}(#{columns_clause}) VALUES(#{values_clause})"
378
+ end
379
+
380
+ def update_record
381
+ run_hook :before_update
382
+
383
+ self.class.execute_batch(update_record_statements) unless changes.empty?
384
+
385
+ run_hook :after_update
386
+ end
387
+
388
+ def update_record_statements
389
+ nils = changes.select { |_, (__, new_value)| new_value.nil? }
390
+ objs = changes.reject { |_, (__, new_value)| new_value.nil? }
391
+
392
+ statements = []
393
+
394
+ if nils.present?
395
+ statements << "DELETE #{nils.keys.join(", ")} FROM #{self.class.quote_table_name self.class.table_name} #{where_key_clause}"
396
+ end
397
+
398
+ if objs.present?
399
+ update_clause = objs.map { |column, (_, new_value)| "#{self.class.quote_column_name column} = #{self.class.quote_value new_value}" }.join(", ")
400
+
401
+ statements << "UPDATE #{self.class.quote_table_name self.class.table_name} SET #{update_clause} #{where_key_clause}"
402
+ end
403
+
404
+ statements
405
+ end
406
+
407
+ def delete_record_statement
408
+ "DELETE FROM #{self.class.quote_table_name self.class.table_name} #{where_key_clause}"
409
+ end
410
+
411
+ def where_key_clause
412
+ "WHERE #{self.class.key_columns.map { |column, _| "#{self.class.quote_column_name column} = #{self.class.quote_value read_raw_attribute(column)}" }.join(" AND ")}"
413
+ end
414
+
415
+ protected
416
+
417
+ def generate_uuid
418
+ @uuid_generator ||= Cassandra::Uuid::Generator.new
419
+ @uuid_generator.uuid
420
+ end
421
+
422
+ def generate_timeuuid(time = Time.now)
423
+ @timeuuid_generator ||= Cassandra::TimeUuid::Generator.new
424
+ @timeuuid_generator.at(time)
425
+ end
426
+ end