github-ds 0.1.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,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