github-ds 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,50 @@
1
+ require File.expand_path("../example_setup", __FILE__)
2
+ require "github/result"
3
+
4
+ def do_something
5
+ 1
6
+ end
7
+
8
+ def do_something_that_errors
9
+ raise "noooooppppeeeee"
10
+ end
11
+
12
+ result = GitHub::Result.new { do_something }
13
+ p result.ok? # => true
14
+ p result.value! # => 1
15
+
16
+ result = GitHub::Result.new { do_something_that_errors }
17
+ p result.ok? # => false
18
+ p result.value { "default when error happens" } # => "default when error happens"
19
+
20
+ begin
21
+ result.value! # => raises exception because error happened
22
+ rescue => error
23
+ p result.error # => the error
24
+ p error # the same error
25
+ end
26
+
27
+ # Outputs Step 1, 2, 3
28
+ result = GitHub::Result.new {
29
+ GitHub::Result.new { puts "Step 1: success!" }
30
+ }.then { |value|
31
+ GitHub::Result.new { puts "Step 2: success!" }
32
+ }.then { |value|
33
+ GitHub::Result.new { puts "Step 3: success!" }
34
+ }
35
+ p result.ok? # => true
36
+
37
+ # Outputs Step 1, 2 and stops.
38
+ result = GitHub::Result.new {
39
+ GitHub::Result.new { puts "Step 1: success!" }
40
+ }.then { |value|
41
+ GitHub::Result.new {
42
+ puts "Step 2: failed!"
43
+ raise
44
+ }
45
+ }.then { |value|
46
+ GitHub::Result.new {
47
+ puts "Step 3: should not get here because previous step failed!"
48
+ }
49
+ }
50
+ p result.ok? # => false
@@ -0,0 +1,44 @@
1
+ require File.expand_path("../example_setup", __FILE__)
2
+ require "github/sql"
3
+
4
+ insert_statement = "INSERT INTO example_key_values (`key`, `value`) VALUES (:key, :value)"
5
+ select_statement = "SELECT value FROM example_key_values WHERE `key` = :key"
6
+ update_statement = "UPDATE example_key_values SET value = :value WHERE `key` = :key"
7
+ delete_statement = "DELETE FROM example_key_values WHERE `key` = :key"
8
+
9
+ ################################# Class Style ##################################
10
+ sql = GitHub::SQL.run insert_statement, key: "foo", value: "bar"
11
+ p sql.last_insert_id
12
+ # 1
13
+
14
+ p GitHub::SQL.value select_statement, key: "foo"
15
+ # "bar"
16
+
17
+ sql = GitHub::SQL.run update_statement, key: "foo", value: "new value"
18
+ p sql.affected_rows
19
+ # 1
20
+
21
+ sql = GitHub::SQL.run delete_statement, key: "foo"
22
+ p sql.affected_rows
23
+ # 1
24
+
25
+
26
+ ################################ Instance Style ################################
27
+ sql = GitHub::SQL.new insert_statement, key: "foo", value: "bar"
28
+ sql.run
29
+ p sql.last_insert_id
30
+ # 2
31
+
32
+ sql = GitHub::SQL.new select_statement, key: "foo"
33
+ p sql.value
34
+ # "bar"
35
+
36
+ sql = GitHub::SQL.new update_statement, key: "foo", value: "new value"
37
+ sql.run
38
+ p sql.affected_rows
39
+ # 1
40
+
41
+ sql = GitHub::SQL.new delete_statement, key: "foo"
42
+ sql.run
43
+ p sql.affected_rows
44
+ # 1
@@ -0,0 +1,42 @@
1
+ require File.expand_path("../example_setup", __FILE__)
2
+ require "github/sql"
3
+
4
+ GitHub::SQL.run <<-SQL
5
+ INSERT INTO example_key_values (`key`, `value`)
6
+ VALUES ("foo", "bar"), ("baz", "wick")
7
+ SQL
8
+
9
+ sql = GitHub::SQL.new "SELECT `VALUE` FROM example_key_values"
10
+
11
+ key = ENV["KEY"]
12
+ unless key.nil?
13
+ sql.add "WHERE `key` = :key", key: key
14
+ end
15
+
16
+ limit = ENV["LIMIT"]
17
+ unless limit.nil?
18
+ sql.add "ORDER BY `key` ASC"
19
+ sql.add "LIMIT :limit", limit: limit.to_i
20
+ end
21
+
22
+ p sql.results
23
+
24
+ # Only select value for key = foo
25
+ # $ env KEY=foo bundle exec ruby examples/sql_add.rb
26
+ # [["bar"]]
27
+ #
28
+ # Only select value for key = bar
29
+ # $ env KEY=bar bundle exec ruby examples/sql_add.rb
30
+ # []
31
+ #
32
+ # Only select value for key = baz
33
+ # $ env KEY=baz bundle exec ruby examples/sql_add.rb
34
+ # [["wick"]]
35
+ #
36
+ # Select all values
37
+ # $ bundle exec ruby examples/sql_add.rb
38
+ # [["bar"], ["wick"]]
39
+ #
40
+ # Select only 1 key.
41
+ # $ env LIMIT=1 bundle exec ruby examples/sql_add.rb
42
+ # [["wick"]]
@@ -0,0 +1,32 @@
1
+ require File.expand_path("../example_setup", __FILE__)
2
+ require "github/sql"
3
+
4
+ class SomeModel < ActiveRecord::Base
5
+ self.abstract_class = true
6
+
7
+ establish_connection({
8
+ adapter: "mysql2",
9
+ database: "github_ds_test",
10
+ })
11
+ end
12
+
13
+ insert_statement = "INSERT INTO example_key_values (`key`, `value`) VALUES (:key, :value)"
14
+
15
+ ActiveRecord::Base.transaction do
16
+ # Insert bar on base connection.
17
+ GitHub::SQL.run insert_statement, key: "bar", value: "baz", connection: ActiveRecord::Base.connection
18
+
19
+ SomeModel.transaction do
20
+ # Insert foo on different connection.
21
+ GitHub::SQL.run insert_statement, key: "foo", value: "bar", connection: SomeModel.connection
22
+ end
23
+
24
+ # Roll back "bar" insertion.
25
+ raise ActiveRecord::Rollback
26
+ end
27
+
28
+ # Show that "bar" key is not here because that connection's transaction was
29
+ # rolled back. SomeModel is a different connection and started a different
30
+ # transaction, which succeeded, so "foo" key was created.
31
+ p GitHub::SQL.values "SELECT `key` FROM example_key_values"
32
+ # ["foo"]
@@ -0,0 +1,42 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "github/ds/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "github-ds"
8
+ spec.version = GitHub::DS::VERSION
9
+ spec.authors = ["GitHub Open Source", "John Nunemaker"]
10
+ spec.email = ["opensource+github-ds@github.com", "nunemaker@gmail.com"]
11
+
12
+ spec.summary = %q{A collection of libraries for working with SQL on top of ActiveRecord's connection.}
13
+ spec.description = %q{A collection of libraries for working with SQL on top of ActiveRecord's connection.}
14
+ spec.homepage = "https://github.com/github/github-ds"
15
+ spec.license = "MIT"
16
+
17
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
19
+ if spec.respond_to?(:metadata)
20
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
21
+ else
22
+ raise "RubyGems 2.0 or newer is required to protect against " \
23
+ "public gem pushes."
24
+ end
25
+
26
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
27
+ f.match(%r{^(test|spec|features)/})
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_development_dependency "bundler", "~> 1.14"
34
+ spec.add_development_dependency "rake", "~> 10.0"
35
+ spec.add_development_dependency "minitest", "~> 5.0"
36
+ spec.add_development_dependency "timecop", "~> 0.8.1"
37
+ spec.add_development_dependency "activerecord", "~> 5.0"
38
+ spec.add_development_dependency "activesupport"
39
+ spec.add_development_dependency "mysql2"
40
+ spec.add_development_dependency "activerecord-mysql-adapter"
41
+ spec.add_development_dependency "mocha", "~> 1.2.1"
42
+ end
@@ -0,0 +1,22 @@
1
+ require "rails/generators/active_record"
2
+
3
+ module Github
4
+ module DS
5
+ module Generators
6
+ class ActiveRecordGenerator < ::Rails::Generators::Base
7
+ include ::Rails::Generators::Migration
8
+ desc "Generates migration for KV table"
9
+
10
+ source_paths << File.join(File.dirname(__FILE__), "templates")
11
+
12
+ def create_migration_file
13
+ migration_template "migration.rb", "db/migrate/create_key_values_table.rb"
14
+ end
15
+
16
+ def self.next_migration_number(dirname)
17
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ class CreateKeyValuesTable < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :key_values do |t|
4
+ t.string :key, :null => false
5
+ t.binary :value, :null => false
6
+ t.datetime :expires_at, :null => true
7
+ t.timestamps :null => false
8
+ end
9
+
10
+ add_index :key_values, :key, :unique => true
11
+ add_index :key_values, :expires_at
12
+
13
+ change_column :key_values, :id, "bigint(20) NOT NULL AUTO_INCREMENT"
14
+ end
15
+
16
+ def self.down
17
+ drop_table :key_values
18
+ end
19
+ end
@@ -0,0 +1 @@
1
+ require "github/ds"
@@ -0,0 +1,8 @@
1
+ require "github/ds/version"
2
+
3
+ module GitHub
4
+ module DS
5
+ end
6
+ end
7
+
8
+ require "github/kv"
@@ -0,0 +1,5 @@
1
+ module GitHub
2
+ module DS
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,343 @@
1
+ require "github/result"
2
+ require "github/sql"
3
+
4
+ # GitHub::KV is a key/value data store backed by MySQL (however, the backing
5
+ # store used should be regarded as an implementation detail).
6
+ #
7
+ # Usage tips:
8
+ #
9
+ # * Components in key names should be ordered by cardinality, from lowest to
10
+ # highest. That is, static key components should be at the front of the key
11
+ # and key components that vary should be at the end of the key in order of
12
+ # how many potential values they might have.
13
+ #
14
+ # For example, if using GitHub::KV to store a user preferences, the key
15
+ # should be named "user.#{preference_name}.#{user_id}". Notice that the
16
+ # part of the key that never changes ("user") comes first, followed by
17
+ # the name of the preference (of which there might be a handful), followed
18
+ # finally by the user id (of which there are millions).
19
+ #
20
+ # This will make it easier to scan for keys later on, which is a necessity
21
+ # if we ever need to move this data out of GitHub::KV or if we need to
22
+ # search the keyspace for some reason (for example, if it's a preference
23
+ # that we're planning to deprecate, putting the preference name near the
24
+ # beginning of the key name makes it easier to search for all users with
25
+ # that preference set).
26
+ #
27
+ # * All reader methods in GitHub::KV return values wrapped inside a Result
28
+ # object.
29
+ #
30
+ # If any of these methods raise an exception for some reason (for example,
31
+ # the database is down), they will return a Result value representing this
32
+ # error rather than raising the exception directly. See lib/github/result.rb
33
+ # for more documentation on GitHub::Result including usage examples.
34
+ #
35
+ # When using GitHub::KV, it's important to handle error conditions and not
36
+ # assume that GitHub::Result objects will always represent success.
37
+ # Code using GitHub::KV should be able to fail partially if
38
+ # GitHub::KV is down. How exactly to do this will depend on a
39
+ # case-by-case basis - it may involve falling back to a default value, or it
40
+ # might involve showing an error message to the user while still letting the
41
+ # rest of the page load.
42
+ #
43
+ module GitHub
44
+ class KV
45
+ MAX_KEY_LENGTH = 255
46
+ MAX_VALUE_LENGTH = 65535
47
+
48
+ KeyLengthError = Class.new(StandardError)
49
+ ValueLengthError = Class.new(StandardError)
50
+ UnavailableError = Class.new(StandardError)
51
+
52
+ class MissingConnectionError < StandardError; end
53
+
54
+ def initialize(encapsulated_errors = [SystemCallError], &conn_block)
55
+ @encapsulated_errors = encapsulated_errors
56
+ @conn_block = conn_block
57
+ end
58
+
59
+ def connection
60
+ @conn_block.try(:call) || (raise MissingConnectionError, "KV must be initialized with a block that returns a connection")
61
+ end
62
+
63
+ # get :: String -> Result<String | nil>
64
+ #
65
+ # Gets the value of the specified key.
66
+ #
67
+ # Example:
68
+ #
69
+ # kv.get("foo")
70
+ # # => #<Result value: "bar">
71
+ #
72
+ # kv.get("octocat")
73
+ # # => #<Result value: nil>
74
+ #
75
+ def get(key)
76
+ validate_key(key)
77
+
78
+ mget([key]).map { |values| values[0] }
79
+ end
80
+
81
+ # mget :: [String] -> Result<[String | nil]>
82
+ #
83
+ # Gets the values of all specified keys. Values will be returned in the
84
+ # same order as keys are specified. nil will be returned in place of a
85
+ # String for keys which do not exist.
86
+ #
87
+ # Example:
88
+ #
89
+ # kv.mget(["foo", "octocat"])
90
+ # # => #<Result value: ["bar", nil]
91
+ #
92
+ def mget(keys)
93
+ validate_key_array(keys)
94
+
95
+ Result.new {
96
+ kvs = GitHub::SQL.results(<<-SQL, :keys => keys, :connection => connection).to_h
97
+ SELECT `key`, value FROM key_values WHERE `key` IN :keys AND (`expires_at` IS NULL OR `expires_at` > NOW())
98
+ SQL
99
+
100
+ keys.map { |key| kvs[key] }
101
+ }
102
+ end
103
+
104
+ # set :: String, String, expires: Time? -> nil
105
+ #
106
+ # Sets the specified key to the specified value. Returns nil. Raises on
107
+ # error.
108
+ #
109
+ # Example:
110
+ #
111
+ # kv.set("foo", "bar")
112
+ # # => nil
113
+ #
114
+ def set(key, value, expires: nil)
115
+ validate_key(key)
116
+ validate_value(value)
117
+
118
+ mset({ key => value }, expires: expires)
119
+ end
120
+
121
+ # mset :: { String => String }, expires: Time? -> nil
122
+ #
123
+ # Sets the specified hash keys to their associated values, setting them to
124
+ # expire at the specified time. Returns nil. Raises on error.
125
+ #
126
+ # Example:
127
+ #
128
+ # kv.mset({ "foo" => "bar", "baz" => "quux" })
129
+ # # => nil
130
+ #
131
+ # kv.mset({ "expires" => "soon" }, expires: 1.hour.from_now)
132
+ # # => nil
133
+ #
134
+ def mset(kvs, expires: nil)
135
+ validate_key_value_hash(kvs)
136
+ validate_expires(expires) if expires
137
+
138
+ rows = kvs.map { |key, value|
139
+ [key, value, GitHub::SQL::NOW, GitHub::SQL::NOW, expires || GitHub::SQL::NULL]
140
+ }
141
+
142
+ encapsulate_error do
143
+ GitHub::SQL.run(<<-SQL, :rows => GitHub::SQL::ROWS(rows), :connection => connection)
144
+ INSERT INTO key_values (`key`, value, created_at, updated_at, expires_at)
145
+ VALUES :rows
146
+ ON DUPLICATE KEY UPDATE
147
+ value = VALUES(value),
148
+ updated_at = VALUES(updated_at),
149
+ expires_at = VALUES(expires_at)
150
+ SQL
151
+ end
152
+
153
+ nil
154
+ end
155
+
156
+ # exists :: String -> Result<Boolean>
157
+ #
158
+ # Checks for existence of the specified key.
159
+ #
160
+ # Example:
161
+ #
162
+ # kv.exists("foo")
163
+ # # => #<Result value: true>
164
+ #
165
+ # kv.exists("octocat")
166
+ # # => #<Result value: false>
167
+ #
168
+ def exists(key)
169
+ validate_key(key)
170
+
171
+ mexists([key]).map { |values| values[0] }
172
+ end
173
+
174
+ # mexists :: [String] -> Result<[Boolean]>
175
+ #
176
+ # Checks for existence of all specified keys. Booleans will be returned in
177
+ # the same order as keys are specified.
178
+ #
179
+ # Example:
180
+ #
181
+ # kv.mexists(["foo", "octocat"])
182
+ # # => #<Result value: [true, false]>
183
+ #
184
+ def mexists(keys)
185
+ validate_key_array(keys)
186
+
187
+ Result.new {
188
+ existing_keys = GitHub::SQL.values(<<-SQL, :keys => keys, :connection => connection).to_set
189
+ SELECT `key` FROM key_values WHERE `key` IN :keys AND (`expires_at` IS NULL OR `expires_at` > NOW())
190
+ SQL
191
+
192
+ keys.map { |key| existing_keys.include?(key) }
193
+ }
194
+ end
195
+
196
+ # setnx :: String, String, expires: Time? -> Boolean
197
+ #
198
+ # Sets the specified key to the specified value only if it does not
199
+ # already exist.
200
+ #
201
+ # Returns true if the key was set, false otherwise. Raises on error.
202
+ #
203
+ # Example:
204
+ #
205
+ # kv.setnx("foo", "bar")
206
+ # # => false
207
+ #
208
+ # kv.setnx("octocat", "monalisa")
209
+ # # => true
210
+ #
211
+ # kv.setnx("expires", "soon", expires: 1.hour.from_now)
212
+ # # => true
213
+ #
214
+ def setnx(key, value, expires: nil)
215
+ validate_key(key)
216
+ validate_value(value)
217
+ validate_expires(expires) if expires
218
+
219
+ encapsulate_error {
220
+ # if the key already exists but has expired, prune it first. We could
221
+ # achieve the same thing with the right INSERT ... ON DUPLICATE KEY UPDATE
222
+ # query, but then we would not be able to rely on affected_rows
223
+
224
+ GitHub::SQL.run(<<-SQL, :key => key, :connection => connection)
225
+ DELETE FROM key_values WHERE `key` = :key AND expires_at <= NOW()
226
+ SQL
227
+
228
+ sql = GitHub::SQL.run(<<-SQL, :key => key, :value => value, :expires => expires || GitHub::SQL::NULL, :connection => connection)
229
+ INSERT IGNORE INTO key_values (`key`, value, created_at, updated_at, expires_at)
230
+ VALUES (:key, :value, NOW(), NOW(), :expires)
231
+ SQL
232
+
233
+ sql.affected_rows > 0
234
+ }
235
+ end
236
+
237
+ # del :: String -> nil
238
+ #
239
+ # Deletes the specified key. Returns nil. Raises on error.
240
+ #
241
+ # Example:
242
+ #
243
+ # kv.del("foo")
244
+ # # => nil
245
+ #
246
+ def del(key)
247
+ validate_key(key)
248
+
249
+ mdel([key])
250
+ end
251
+
252
+ # mdel :: String -> nil
253
+ #
254
+ # Deletes the specified keys. Returns nil. Raises on error.
255
+ #
256
+ # Example:
257
+ #
258
+ # kv.mdel(["foo", "octocat"])
259
+ # # => nil
260
+ #
261
+ def mdel(keys)
262
+ validate_key_array(keys)
263
+
264
+ encapsulate_error do
265
+ GitHub::SQL.run(<<-SQL, :keys => keys, :connection => connection)
266
+ DELETE FROM key_values WHERE `key` IN :keys
267
+ SQL
268
+ end
269
+
270
+ nil
271
+ end
272
+
273
+ private
274
+ def validate_key(key)
275
+ raise TypeError, "key must be a String in #{self.class.name}, but was #{key.class}" unless key.is_a?(String)
276
+
277
+ validate_key_length(key)
278
+ end
279
+
280
+ def validate_value(value)
281
+ raise TypeError, "value must be a String in #{self.class.name}, but was #{value.class}" unless value.is_a?(String)
282
+
283
+ validate_value_length(value)
284
+ end
285
+
286
+ def validate_key_array(keys)
287
+ unless keys.is_a?(Array)
288
+ raise TypeError, "keys must be a [String] in #{self.class.name}, but was #{keys.class}"
289
+ end
290
+
291
+ keys.each do |key|
292
+ unless key.is_a?(String)
293
+ raise TypeError, "keys must be a [String] in #{self.class.name}, but also saw at least one #{key.class}"
294
+ end
295
+
296
+ validate_key_length(key)
297
+ end
298
+ end
299
+
300
+ def validate_key_value_hash(kvs)
301
+ unless kvs.is_a?(Hash)
302
+ raise TypeError, "kvs must be a {String => String} in #{self.class.name}, but was #{key.class}"
303
+ end
304
+
305
+ kvs.each do |key, value|
306
+ unless key.is_a?(String)
307
+ raise TypeError, "kvs must be a {String => String} in #{self.class.name}, but also saw at least one key of type #{key.class}"
308
+ end
309
+
310
+ unless value.is_a?(String)
311
+ raise TypeError, "kvs must be a {String => String} in #{self.class.name}, but also saw at least one value of type #{value.class}"
312
+ end
313
+
314
+ validate_key_length(key)
315
+ validate_value_length(value)
316
+ end
317
+ end
318
+
319
+ def validate_key_length(key)
320
+ if key.length > MAX_KEY_LENGTH
321
+ raise KeyLengthError, "key of length #{key.length} exceeds maximum key length of #{MAX_KEY_LENGTH}\n\nkey: #{key.inspect}"
322
+ end
323
+ end
324
+
325
+ def validate_value_length(value)
326
+ if value.length > MAX_VALUE_LENGTH
327
+ raise ValueLengthError, "value of length #{value.length} exceeds maximum value length of #{MAX_VALUE_LENGTH}"
328
+ end
329
+ end
330
+
331
+ def validate_expires(expires)
332
+ unless expires.respond_to?(:to_time)
333
+ raise TypeError, "expires must be a time of some sort (Time, DateTime, ActiveSupport::TimeWithZone, etc.), but was #{expires.class}"
334
+ end
335
+ end
336
+
337
+ def encapsulate_error
338
+ yield
339
+ rescue *@encapsulated_errors => error
340
+ raise UnavailableError, "#{error.class}: #{error.message}"
341
+ end
342
+ end
343
+ end