activerecord-spanner-adapter 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8ff8fa6b4d8ed65a702bde98a87cd0c8f2297d66
4
+ data.tar.gz: bfce942a21417f01f4aec9c211c7c27fc7a5b278
5
+ SHA512:
6
+ metadata.gz: 54da92b648a12aa2f8add6dc52f03c1255fd529c178aadfcfb112520d8f6c918095bcdd6c33907f848da124edcf6a390e5c39133c0ebc35ce2baf9b53fb6effd
7
+ data.tar.gz: 88072ff07c34f9d9d2a3314a0c8b258bc3bb34433e811c6a825775ffa663852ca3a01bf30e992c94ce2e029ce4c2ec87f1967175532c34af4231787459f1b5a8
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.gitmodules ADDED
@@ -0,0 +1,3 @@
1
+ [submodule "gcloud-ruby"]
2
+ path = vendor/gcloud-ruby
3
+ url = https://github.com/GoogleCloudPlatform/google-cloud-ruby
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.3
5
+ before_install: gem install bundler -v 1.14.4
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in activerecord-spanner-adapter.gemspec
4
+ gemspec
5
+
6
+ gem 'google-cloud-spanner', path: 'vendor/gcloud-ruby/google-cloud-spanner'
7
+ gem 'pry'
8
+ gem 'pry-byebug'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Supership Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # ActiveRecord Spanner adapter
2
+
3
+ The [Cloud Spanner](https://cloud.google.com/spanner/) adapter for ActiveRecord.
4
+
5
+ ## Status
6
+ Proof of concept.
7
+ You cannot expect that this gem is ready for production use -- many features are not supported.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'activerecord-spanner-adapter',
15
+ git: 'https://github.com/supership-jp/activerecord-spanner-adapter.git'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ $ bundle
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem install activerecord-spanner-adapter
25
+
26
+ ## Usage
27
+
28
+ Add a configuration like this into your `database.yml`.
29
+
30
+ ```yaml
31
+ default:
32
+ adapter: spanner
33
+ project: your-gcp-project-name
34
+ instance: your-spanner-instance-name
35
+ database: your-spanner-database-name
36
+ keyfile: path/to/serivce-account-credential.json
37
+ ```
38
+
39
+ *NOTE*: This adapter uses UUIDs as primary keys by default unlike other adapters.
40
+ This is because monotonically increasing primary key restricts write performance in Spanner.
41
+
42
+ c.f. https://cloud.google.com/spanner/docs/best-practices
43
+
44
+
45
+ ## Development
46
+
47
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
48
+
49
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
50
+
51
+ ## Contributing
52
+
53
+ Bug reports and pull requests are welcome on GitHub at https://github.com/supership-jp/activerecord-spanner-adapter.
54
+
55
+ ## License
56
+ Copyright (c) 2017 Supership Inc.
57
+
58
+ Licensed under MIT license.
59
+
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'activerecord-spanner-adapter/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "activerecord-spanner-adapter"
8
+ spec.version = ActiveRecordSpannerAdapter::VERSION
9
+ spec.authors = ["Yuki Yugui Sonoda"]
10
+ spec.email = ["yuki.sonoda@supership.jp"]
11
+
12
+ spec.summary = %q{Adapts Google Cloud Spanner to ActiveRecord}
13
+ spec.description = %q{Connection Adapter of Google Cloud Spanner to ActiveRecord O/R mapper library}
14
+ spec.homepage = "https://github.com/supership-jp/activerecord-spanner-adapter"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency 'activerecord', "~> 5.0"
22
+ spec.add_dependency 'google-cloud-spanner'
23
+ spec.add_dependency 'google-gax', '~> 0.8'
24
+ spec.add_development_dependency "bundler", "~> 1.14"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "minitest", "~> 5.0"
27
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "activerecord/spanner/adapter"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,10 @@
1
+ require "active_record"
2
+ require "active_record/connection_adapters/spanner_adapter"
3
+
4
+ module ActiveRecord
5
+ module ConnectionAdapters
6
+ module Spanner
7
+ include ActiveRecordSpannerAdapter
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,320 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Spanner
4
+ module DatabaseStatements
5
+ include ConnectionAdapters::DatabaseStatements
6
+
7
+ class QueryVisitor < ::Arel::Visitors::ToSql
8
+ def visit_Arel_Nodes_BindParam(o, collector)
9
+ collector.add_bind(o) {|bind_idx| "@p#{bind_idx}" }
10
+ end
11
+ end
12
+
13
+ # A mixin which provides ways to resolve rvalues.
14
+ module RightValueResolveable
15
+ def visit_Arel_Nodes_BindParam(o)
16
+ binds.shift
17
+ end
18
+
19
+ def visit_Array(o)
20
+ o.map {|item| accept(item) }
21
+ end
22
+
23
+ def visit_Arel_Nodes_Casted(o)
24
+ a = o.attribute
25
+ if a.able_to_type_cast?
26
+ a.type_cast_for_database(o.val)
27
+ else
28
+ o.val
29
+ end
30
+ end
31
+
32
+ private
33
+ # To be overridden
34
+ def binds
35
+ raise NotImplementedError
36
+ end
37
+ end
38
+
39
+ # Converts ASTs of INSERT, UPDATE or DELETE statements into forms
40
+ # convenient for DatabaseStatements#insert, #update and #delete.
41
+ class MutationVisitor < ::Arel::Visitors::Visitor
42
+ include RightValueResolveable
43
+
44
+ def initialize(schema_reader, binds)
45
+ super()
46
+ @schema_reader = schema_reader
47
+ @binds = binds
48
+ end
49
+
50
+ attr_reader :binds
51
+ private :binds
52
+
53
+ def visit_Arel_Nodes_InsertStatement(o)
54
+ raise NotImplementedError, 'INSERT INTO SELECT statement is not supported' if o.select
55
+ table = o.relation.name
56
+ columns = if o.columns.any?
57
+ o.columns.map(&:name)
58
+ else
59
+ columns(table).map(&:name)
60
+ end
61
+ values = o.values ? accept(o.values) : []
62
+
63
+ [table, columns, values]
64
+ end
65
+
66
+ def visit_Arel_Nodes_UpdateStatement(o)
67
+ table = o.relation.name
68
+ values = accept(o.values)
69
+ pk = @schema_reader.primary_key(table)
70
+
71
+ unless o.orders.empty? and o.limit.nil? and o.wheres.size == 1
72
+ raise NotImplementedError, 'UPDATE statements with ORDER, LIMIT or complex WHERE clause'
73
+ end
74
+
75
+ ids = WhereVisitor.new(o.relation, pk, binds).accept(o.wheres[0])
76
+ raise NotImplementedError 'UPDATE statements with complex WHERE clause' unless ids
77
+
78
+ [table, ids, values]
79
+ end
80
+
81
+ def visit_Arel_Nodes_DeleteStatement(o)
82
+ table = o.relation.name
83
+
84
+ # fallback_result lets the caller query the target id set at first and then
85
+ # delete the ids.
86
+ fallback_result = [table, nil, o.wheres]
87
+
88
+ case o.wheres.size
89
+ when 0
90
+ return [table, :all]
91
+ when 1
92
+ # it might be a simple "id = ?". Let's check later
93
+ else
94
+ return fallback_result
95
+ end
96
+
97
+ pk = @schema_reader.primary_key(table)
98
+ ids = WhereVisitor.new(o.relation, pk, binds).accept(o.wheres[0])
99
+
100
+ if ids
101
+ return [table, ids]
102
+ else
103
+ return fallback_result
104
+ end
105
+ end
106
+
107
+ def visit_Arel_Nodes_Values o
108
+ o.expressions.map.with_index do |value|
109
+ case value
110
+ when ::Arel::Nodes::SqlLiteral
111
+ raise NotImplementedError, "mutation with SQL literal is not supported"
112
+ else
113
+ accept(value)
114
+ end
115
+ end
116
+ end
117
+
118
+ def visit_Arel_Nodes_Assignment(o)
119
+ [accept(o.left), accept(o.right)]
120
+ end
121
+
122
+ def visit_Arel_Nodes_UnqualifiedColumn(o)
123
+ o.name
124
+ end
125
+ end
126
+
127
+ # Tries to convert where clause into a set of ids if it is simple enough.
128
+ # Returns nil if the clause is not simple.
129
+ class WhereVisitor < ::Arel::Visitors::Visitor
130
+ include RightValueResolveable
131
+
132
+ NOT_SIMPLE = nil
133
+
134
+ def initialize(relation, pk, binds)
135
+ super()
136
+ @relation = relation
137
+ @pk = pk
138
+ @binds = binds
139
+ end
140
+
141
+ attr_reader :binds
142
+ private :binds
143
+
144
+ def visit_Arel_Nodes_And(o)
145
+ if o.children.size == 1
146
+ accept(o.left)
147
+ else
148
+ NOT_SIMPLE
149
+ end
150
+ end
151
+
152
+ def visit_Arel_Nodes_Equality(o)
153
+ if pk_cond?(o)
154
+ [accept(o.right)]
155
+ else
156
+ NOT_SIMPLE
157
+ end
158
+ end
159
+
160
+ def visit_Arel_Nodes_In(o)
161
+ return nil unless pk_cond?(o)
162
+
163
+ if o.kind_of?(Array) and o.empty?
164
+ []
165
+ else
166
+ accept(o.right)
167
+ end
168
+ end
169
+
170
+ def unsupported(o)
171
+ return NOT_SIMPLE
172
+ end
173
+
174
+ alias visit_Arel_Nodes_Grouping unsupported
175
+ alias visit_Arel_Nodes_NotIn unsupported
176
+ alias visit_Arel_Nodes_Or unsupported
177
+ alias visit_Arel_Nodes_NotEqual unsupported
178
+ alias visit_Arel_Nodes_Case unsupported
179
+ alias visit_Arel_Nodes_Between unsupported
180
+ alias visit_Arel_Nodes_GreaterThanOrEqual unsupported
181
+ alias visit_Arel_Nodes_GreaterThan unsupported
182
+ alias visit_Arel_Nodes_LessThanOrEqual unsupported
183
+ alias visit_Arel_Nodes_LessThan unsupported
184
+ alias visit_Arel_Nodes_Matches unsupported
185
+ alias visit_Arel_Nodes_DoesNotMatch unsupported
186
+
187
+ private
188
+ def pk_cond?(o)
189
+ o.left.kind_of?(Arel::Attributes::Attribute) &&
190
+ o.left.relation == @relation &&
191
+ o.left.name == @pk
192
+ end
193
+ end
194
+
195
+ def insert(arel, name = nil, pk = nil, id_value = nil, sequence_name = nil, binds = [])
196
+ raise NotImplementedError, "INSERT in raw SQL is not supported" unless arel.respond_to?(:ast)
197
+
198
+ type_casted_binds = binds.map {|attr| type_cast(attr.value_for_database) }
199
+ table, columns, values = MutationVisitor.new(self, type_casted_binds).accept(arel.ast)
200
+ fake_sql = <<~"SQL"
201
+ INSERT INTO #{table}(#{columns.join(", ")}) VALUES (#{values.join(", ")})
202
+ SQL
203
+
204
+ row = columns.zip(values).inject({}) {|out, (col, value)|
205
+ out[col] = value
206
+ out
207
+ }
208
+
209
+ log(fake_sql, name) do
210
+ session.commit do |c|
211
+ c.insert table, row
212
+ end
213
+ end
214
+
215
+ id_value
216
+ end
217
+
218
+ def update(arel, name = nil, binds = [])
219
+ raise NotImplementedError, "DELETE in raw SQL is not supported" unless arel.respond_to?(:ast)
220
+
221
+ type_casted_binds = binds.map {|attr| type_cast(attr.value_for_database) }
222
+ table, target, values = MutationVisitor.new(self, type_casted_binds.dup).accept(arel.ast)
223
+
224
+ fake_set = values.map {|col, val|
225
+ "#{quote_column_name(col)} = #{quote(val)}"
226
+ }.join(', ')
227
+
228
+ pk = primary_key(table)
229
+ if target.size > 1
230
+ fake_sql = "UPDATE #{quote_column_name(table)} SET #{fake_set} WHERE #{pk} IN ?"
231
+ else
232
+ fake_sql = "UPDATE #{quote_column_name(table)} SET #{fake_set} WHERE #{pk} = ?"
233
+ end
234
+
235
+ row = values.inject({}) {|r, (col, val)| r[col] = val; r }
236
+ rows = target.map {|id| row.merge(pk => id) }
237
+
238
+ log(fake_sql, name, binds) do
239
+ session.commit do |c|
240
+ c.update(table, rows)
241
+ end
242
+ end
243
+
244
+ true
245
+ end
246
+
247
+ def delete(arel, name, binds)
248
+ raise NotImplementedError, "DELETE in raw SQL is not supported" unless arel.respond_to?(:ast)
249
+
250
+ type_casted_binds = binds.map {|attr| type_cast(attr.value_for_database) }
251
+ table, target, wheres = MutationVisitor.new(self, type_casted_binds.dup).accept(arel.ast)
252
+
253
+ # TODO(yugui) Support composite primary key?
254
+ pk = primary_key(table)
255
+ if target.nil?
256
+ where_clause = visitor.accept(wheres, collector).compile(binds.dup, self)
257
+ # TODO(yugui) keep consistency with transaction
258
+ target = select_values(<<~"SQL", name, binds)
259
+ SELECT #{quote_column_name(pk)} FROM #{quote_table_name(table)} WHERE #{where_clause}
260
+ SQL
261
+ end
262
+
263
+ if target == :all
264
+ keyset = []
265
+ fake_sql = "DELETE FROM #{quote_column_name(table)}"
266
+ elsif target.size > 1
267
+ fake_sql = "DELETE FROM #{quote_column_name(table)} WHERE (primary-key) IN ?"
268
+ keyset = target
269
+ else
270
+ fake_sql = "DELETE FROM #{quote_column_name(table)} WHERE (primary-key) = ?"
271
+ keyset = target
272
+ end
273
+
274
+ log(fake_sql, name, binds) do
275
+ session.commit do |c|
276
+ c.delete(table, keyset)
277
+ end
278
+ end
279
+
280
+ keyset.size
281
+ end
282
+
283
+ def execute(stmt)
284
+ case stmt
285
+ when Spanner::DDL
286
+ execute_ddl(stmt)
287
+ else
288
+ super(stmt)
289
+ end
290
+ end
291
+
292
+ def exec_query(sql, name = 'SQL', binds = [], prepare: :ignored)
293
+ case
294
+ when binds.kind_of?(Hash)
295
+ spanner_binds = binds
296
+ binds = binds.values
297
+ when binds.respond_to?(:to_hash)
298
+ spanner_binds = binds.to_hash
299
+ binds = spanner_binds.values
300
+ else
301
+ spanner_binds = binds.each_with_index.inject({}) {|b, (attr, i)|
302
+ b["p#{i+1}"] = type_cast(attr.value_for_database)
303
+ b
304
+ }
305
+ end
306
+
307
+ log(sql, name, binds) do
308
+ results = session.execute(sql, params: spanner_binds, streaming: false)
309
+ columns = results.types.map(&:first)
310
+ rows = results.rows.map {|row|
311
+ columns.map {|col| row[col] }
312
+ }
313
+ ActiveRecord::Result.new(columns.map(&:to_s), rows)
314
+ end
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
320
+
@@ -0,0 +1,68 @@
1
+ # -*- frozen_string_literal: true -*-
2
+ require 'json'
3
+
4
+ module ActiveRecord
5
+ module ConnectionAdapters
6
+ module Spanner
7
+ module Quoting
8
+ IDENTIFIERS_PATTERN = /\A[a-zA-Z][a-zA-Z0-9_]*\z/
9
+
10
+ def quote_identifier(name)
11
+ # https://cloud.google.com/spanner/docs/data-definition-language?hl=ja#ddl_syntax
12
+ # raise ArgumentError, "invalid table name #{name}" unless IDENTIFIERS_PATTERN =~ name
13
+ "`#{name}`"
14
+ end
15
+
16
+ alias quote_table_name quote_identifier
17
+ alias quote_column_name quote_identifier
18
+
19
+ private
20
+ def _type_cast(value)
21
+ # NOTE: Spanner APIs are strongly typed unlike typical SQL interfaces.
22
+ # So we don't want to serialize the value into string unlike other adapters.
23
+ case value
24
+ when Symbol, ActiveSupport::Multibyte::Chars, Type::Binary::Data
25
+ value.to_s
26
+ else
27
+ value
28
+ end
29
+ end
30
+
31
+ def _quote(value)
32
+ case value
33
+ when Symbol, String, ActiveSupport::Multibyte::Chars, Type::Binary::Data
34
+ quote_string(value.to_s)
35
+ when true
36
+ quoted_true
37
+ when false
38
+ quoted_false
39
+ when nil
40
+ 'NULL'
41
+ when Numeric, ActiveSupport::Duration
42
+ value.to_s
43
+ when Type::Time::Value
44
+ %Q["#{quoted_time(value)}"]
45
+ when Date, Time
46
+ %Q["#{quoted_date(value)}"]
47
+ else
48
+ raise TypeError, "can't quote #{value.class.name}"
49
+ end
50
+ end
51
+
52
+ def quote_string(value)
53
+ # Not sure but string-escape syntax in SELECT statements in Spanner
54
+ # looks to be the one in JSON by observation.
55
+ JSON.generate(value)
56
+ end
57
+
58
+ def quoted_true
59
+ 'true'
60
+ end
61
+
62
+ def quoted_false
63
+ 'false'
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,16 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Spanner
4
+ class DDL < String; end
5
+
6
+ class SchemaCreation < AbstractAdapter::SchemaCreation
7
+ def visit_TableDefinition(o)
8
+ pk = o.columns.find {|c| c.type == :primary_key }
9
+ ddl = "#{super} PRIMARY KEY (#{quote_column_name(pk.name)})"
10
+ DDL.new(ddl)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+
@@ -0,0 +1,175 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Spanner
4
+ module SchemaStatements
5
+ include ConnectionAdapters::SchemaStatements
6
+
7
+ NATIVE_DATABASE_TYPES = {
8
+ primary_key: 'STRING(36)',
9
+ string: { name: 'STRING', limit: 255 },
10
+ text: { name: 'STRING', limit: 'MAX' },
11
+ integer: { name: 'INT64' },
12
+ float: { name: 'FLOAT64' },
13
+ datetime: { name: 'TIMESTAMP' },
14
+ date: { name: 'DATE' },
15
+ binary: { name: 'BYTES', limit: 'MAX' },
16
+ boolean: { name: 'BOOL' },
17
+ }
18
+
19
+ def native_database_types # :nodoc:
20
+ NATIVE_DATABASE_TYPES
21
+ end
22
+
23
+ def tables
24
+ # https://cloud.google.com/spanner/docs/information-schema
25
+ select_values(<<-SQL, 'SCHEMA')
26
+ SELECT
27
+ t.table_name
28
+ FROM
29
+ information_schema.tables AS t
30
+ WHERE
31
+ t.table_catalog = '' AND t.table_schema = ''
32
+ SQL
33
+ end
34
+
35
+ def views
36
+ []
37
+ end
38
+
39
+ def indexes(table, name = :ignored)
40
+ params = {table: table}
41
+ results = exec_query(<<-"SQL", 'SCHEMA', params, prepare: false)
42
+ SELECT
43
+ idx.index_name,
44
+ idx.index_type,
45
+ idx.parent_table_name,
46
+ idx.is_unique,
47
+ idx.is_null_filtered
48
+ FROM
49
+ information_schema.indexes AS idx
50
+ WHERE
51
+ idx.table_catalog = '' AND
52
+ idx.table_schema = '' AND
53
+ idx.table_name = @table
54
+ SQL
55
+
56
+ results.map do |row|
57
+ col_params = { table: table, index: row['index_name'] }
58
+ col_results = exec_query(<<-"SQL", 'SCHEMA', col_params, prepare: false)
59
+ SELECT
60
+ col.column_name,
61
+ col.column_ordering
62
+ FROM
63
+ information_schema.index_columns AS col
64
+ WHERE
65
+ col.table_catalog = '' AND
66
+ col.table_schema = '' AND
67
+ col.table_name = @table AND
68
+ col.index_name = @index
69
+ ORDER BY
70
+ col.ordinal_position
71
+ SQL
72
+
73
+ IndexDefinition.new(
74
+ table,
75
+ row['index_name'],
76
+ row['is_unique'],
77
+ col_results.map {|row| row['column_name'] },
78
+ nil, # length
79
+ col_results.map {|row| row['column_ordering'] },
80
+ nil, # where
81
+ row['index_type'],
82
+ )
83
+ end
84
+ end
85
+
86
+ def columns(table)
87
+ params = {table: table}
88
+ results = exec_query(<<-'SQL', 'SCHEMA', params, prepare: false)
89
+ SELECT
90
+ col.column_name,
91
+ col.column_default,
92
+ col.is_nullable,
93
+ col.spanner_type
94
+ FROM
95
+ information_schema.columns AS col
96
+ WHERE
97
+ col.table_catalog = '' AND
98
+ col.table_schema = '' AND
99
+ col.table_name = @table
100
+ ORDER BY
101
+ col.ordinal_position
102
+ SQL
103
+
104
+ results.map do |row|
105
+ Column.new(
106
+ row['column_name'],
107
+ row['column_default'],
108
+ fetch_type_metadata(row['spanner_type']),
109
+ row['is_nullable'],
110
+ table,
111
+ )
112
+ end
113
+ end
114
+
115
+ def primary_keys(table_name) # :nodoc:
116
+ indexes(table_name).find {|index|
117
+ index.type == 'PRIMARY_KEY'
118
+ }.columns
119
+ end
120
+
121
+ def create_database(name, instance_id: nil, statements: [])
122
+ service = instance.service
123
+ job = service.create_database(instance_id || instance.instance_id, name,
124
+ statements: statements)
125
+ job.wait_until_done! unless job.done?
126
+ raise_on_error(job)
127
+ end
128
+
129
+ def drop_database(name, instance_id: nil)
130
+ service = instance.service
131
+ service.drop_database(instance_id || instance.instance_id, name)
132
+ end
133
+
134
+ def drop_table(name, options = {})
135
+ raise NotImplementedError, 'if_exists in drop_table' if options[:if_exists]
136
+ raise NotImplementedError, 'force in drop_table' if options[:force]
137
+
138
+ ddls = indexes(name).select {|index|
139
+ index.type != 'PRIMARY_KEY'
140
+ }.map {|index|
141
+ "DROP INDEX #{index.name}"
142
+ }
143
+
144
+ ddls << "DROP TABLE #{quote_table_name(name)}"
145
+ execute_ddl(*ddls)
146
+ end
147
+
148
+ def add_index(table_name, column_name, options = {})
149
+ index_name, index_type, index_columns, index_options = add_index_options(table_name, column_name, options)
150
+ execute_ddl(<<-"SQL")
151
+ CREATE #{index_type} INDEX
152
+ #{quote_column_name(index_name)}
153
+ ON
154
+ #{quote_table_name(table_name)} (#{index_columns})
155
+ #{index_options}
156
+ SQL
157
+ end
158
+
159
+ def execute_ddl(*ddls)
160
+ log(ddls.join(";\n"), 'SCHEMA') do
161
+ job = database.update(statements: ddls.map(&:to_str))
162
+ job.wait_until_done! unless job.done?
163
+ raise_on_error(job.grpc)
164
+ end
165
+ end
166
+
167
+ private
168
+ def raise_on_error(job)
169
+ raise Google::Cloud::Error.from_error(job.error) if job.error?
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+
@@ -0,0 +1,115 @@
1
+ require 'google/cloud/spanner'
2
+
3
+ require 'active_record/connection_adapters/abstract_adapter'
4
+ require 'active_record/connection_adapters/spanner/database_statements'
5
+ require 'active_record/connection_adapters/spanner/schema_creation'
6
+ require 'active_record/connection_adapters/spanner/schema_statements'
7
+ require 'active_record/connection_adapters/spanner/quoting'
8
+
9
+ module ActiveRecord
10
+ module ConnectionHandling
11
+ def spanner_connection(config)
12
+ ConnectionAdapters::SpannerAdapter.new(nil, logger, config)
13
+ end
14
+ end
15
+
16
+ module ConnectionAdapters
17
+ # A Google Cloud Spanner adapter
18
+ #
19
+ # Options:
20
+ # - project
21
+ class SpannerAdapter < AbstractAdapter
22
+ ADAPTER_NAME = 'Spanner'.freeze
23
+ CLIENT_PARAMS = [:project, :keyfile, :scope, :timeout, :client_config].freeze
24
+ ADAPTER_OPTS = (CLIENT_PARAMS + [:instance, :database]).freeze
25
+
26
+ include Spanner::SchemaStatements
27
+ include Spanner::DatabaseStatements
28
+ include Spanner::Quoting
29
+
30
+ def initialize(connection, logger, config)
31
+ super(connection, logger, config)
32
+ conn_params = config.symbolize_keys.slice(*ADAPTER_OPTS)
33
+ connect(conn_params)
34
+ end
35
+
36
+ def schema_creation # :nodoc:
37
+ Spanner::SchemaCreation.new self
38
+ end
39
+
40
+ def arel_visitor
41
+ QueryVisitor.new(self)
42
+ end
43
+
44
+ def active?
45
+ !!@client
46
+ # TODO(yugui) Check db.service.channel.connectivity_state once it is fixed?
47
+ end
48
+
49
+ def connect(params)
50
+ client_params = params.slice(*CLIENT_PARAMS)
51
+ @client = Google::Cloud::Spanner.new(**client_params)
52
+ @instance_id = params[:instance]
53
+ @database_id = params[:database]
54
+ end
55
+
56
+ def disconnect!
57
+ invalidate_session
58
+ end
59
+
60
+ def prefetch_primary_key?(table_name = nil)
61
+ true
62
+ end
63
+
64
+ def next_sequence_value(table_name = nil)
65
+ require 'securerandom'
66
+ SecureRandom.uuid
67
+ end
68
+
69
+ private
70
+ attr_reader :client
71
+
72
+ def initialize_type_map(m) # :nodoc:
73
+ register_class_with_limit m, %r(STRING)i, Type::String
74
+ register_class_with_limit m, %r(BYTES)i, Type::Binary
75
+ m.register_type %r[STRING(MAX)]i, Type::Text.new(limit: 10 * 1024**2)
76
+ m.register_type %r[BYTES(MAX)]i, Type::Binary.new(limit: 10 * 1024**2)
77
+ m.register_type %r[BOOL]i, Type::Boolean.new
78
+ m.register_type %r[INT64]i, Type::Integer.new(limit: 8)
79
+ m.register_type %r[FLOAT64]i, Type::Float.new(limit: 53)
80
+ m.register_type %r[DATE]i, Type::Date.new
81
+ m.register_type %r[TIMESTAMP]i, Type::DateTime.new
82
+ # TODO(yugui) Support array and struct
83
+ end
84
+
85
+
86
+ def instance
87
+ @instance ||= client.instance(@instance_id)
88
+ raise ActiveRecord::NoDatabaseError unless @instance
89
+
90
+ @instance
91
+ end
92
+
93
+ def database
94
+ return @db if @db
95
+
96
+ @db = instance.database(@database_id)
97
+ raise ActiveRecord::NoDatabaseError unless @db
98
+ raise ActiveRecord::ConnectionNotEstablished,
99
+ "database #{@db.database_path} is not ready" unless @db.ready?
100
+
101
+ @db
102
+ end
103
+
104
+ def session
105
+ @session ||= database.session
106
+ end
107
+
108
+ def invalidate_session
109
+ @session&.delete_session
110
+ @session = nil
111
+ end
112
+ end
113
+ end
114
+ end
115
+
@@ -0,0 +1,3 @@
1
+ module ActiveRecordSpannerAdapter
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-spanner-adapter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yuki Yugui Sonoda
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-03-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: google-cloud-spanner
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: google-gax
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.8'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.8'
55
+ - !ruby/object:Gem::Dependency
56
+ name: bundler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.14'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.14'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '10.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '10.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: minitest
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '5.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '5.0'
97
+ description: Connection Adapter of Google Cloud Spanner to ActiveRecord O/R mapper
98
+ library
99
+ email:
100
+ - yuki.sonoda@supership.jp
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - ".gitignore"
106
+ - ".gitmodules"
107
+ - ".travis.yml"
108
+ - Gemfile
109
+ - LICENSE
110
+ - README.md
111
+ - Rakefile
112
+ - activerecord-spanner-adapter.gemspec
113
+ - bin/console
114
+ - bin/setup
115
+ - lib/active_record/connection_adapters/spanner.rb
116
+ - lib/active_record/connection_adapters/spanner/database_statements.rb
117
+ - lib/active_record/connection_adapters/spanner/quoting.rb
118
+ - lib/active_record/connection_adapters/spanner/schema_creation.rb
119
+ - lib/active_record/connection_adapters/spanner/schema_statements.rb
120
+ - lib/active_record/connection_adapters/spanner_adapter.rb
121
+ - lib/activerecord-spanner-adapter/version.rb
122
+ homepage: https://github.com/supership-jp/activerecord-spanner-adapter
123
+ licenses: []
124
+ metadata: {}
125
+ post_install_message:
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubyforge_project:
141
+ rubygems_version: 2.6.8
142
+ signing_key:
143
+ specification_version: 4
144
+ summary: Adapts Google Cloud Spanner to ActiveRecord
145
+ test_files: []