cassandra_store 1.0.0

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