activerecord-spanner-adapter 0.1.0

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