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.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.rubocop.yml +123 -0
- data/.travis.yml +14 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +282 -0
- data/Rakefile +8 -0
- data/cassandra_store.gemspec +31 -0
- data/docker-compose.yml +9 -0
- data/lib/cassandra_store.rb +18 -0
- data/lib/cassandra_store/base.rb +426 -0
- data/lib/cassandra_store/migration.rb +41 -0
- data/lib/cassandra_store/railtie.rb +7 -0
- data/lib/cassandra_store/relation.rb +187 -0
- data/lib/cassandra_store/schema_migration.rb +11 -0
- data/lib/cassandra_store/tasks/cassandra.rake +45 -0
- data/lib/cassandra_store/version.rb +3 -0
- data/spec/cassandra_store/base_spec.rb +691 -0
- data/spec/cassandra_store/migration_spec.rb +79 -0
- data/spec/cassandra_store/relation_spec.rb +222 -0
- data/spec/cassandra_store/schema_migration_spec.rb +19 -0
- data/spec/fixtures/1589957812_migration1.rb +5 -0
- data/spec/fixtures/1589957813_migration2.rb +5 -0
- data/spec/spec_helper.rb +87 -0
- metadata +214 -0
data/Rakefile
ADDED
@@ -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
|
data/docker-compose.yml
ADDED
@@ -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
|