volt-sql 0.0.1

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,438 @@
1
+ require 'sequel'
2
+ require 'sequel/extensions/pg_json'
3
+ require 'sql/lib/where_call'
4
+ require 'fileutils'
5
+ require 'thread'
6
+ require 'sql/lib/migration'
7
+ require 'sql/lib/migration_generator'
8
+ require 'sql/lib/reconcile'
9
+ require 'sql/lib/sql_logger'
10
+ require 'sql/lib/helper'
11
+ require 'volt/utils/data_transformer'
12
+
13
+
14
+ module Volt
15
+ class DataStore
16
+ class SqlAdaptorServer < BaseAdaptorServer
17
+ include Volt::Sql::SqlLogger
18
+
19
+ attr_reader :db, :sql_db, :adaptor_name
20
+
21
+ # :reconcile_complete is set to true after the initial load and reconcile.
22
+ # Any models created after this point will attempt to be auto-reconciled.
23
+ # This is mainly used for specs.
24
+ attr_reader :reconcile_complete
25
+
26
+
27
+ def initialize(volt_app)
28
+ @volt_app = volt_app
29
+ @db_mutex = Mutex.new
30
+
31
+ Sequel.default_timezone = :utc
32
+ super
33
+
34
+ # Add an invalidation callback when we add a new field. This ensures
35
+ # any fields added after the first db call (which triggers the
36
+ # reconcile) will reconcile again.
37
+ inv_reconcile = lambda { invalidate_reconcile! }
38
+ Volt::Model.instance_eval do
39
+ alias :__field__ :field
40
+
41
+ define_singleton_method(:field) do |*args, &block|
42
+ # Call original field
43
+ result = __field__(*args, &block)
44
+ inv_reconcile.call
45
+ result
46
+ end
47
+ end
48
+
49
+ @volt_app.on('boot') do
50
+ # call ```db``` to reconcile
51
+ @app_booted = true
52
+ db
53
+ end
54
+ end
55
+
56
+ # Set db_driver on public
57
+ Volt.configure do |config|
58
+ config.db_driver = 'sqlite'
59
+ end
60
+
61
+ # check if the database can be connected to.
62
+ # @return Boolean
63
+ def connected?
64
+ return true
65
+ begin
66
+ db
67
+
68
+ true
69
+ rescue ::Sequel::ConnectionFailure => e
70
+ false
71
+ end
72
+ end
73
+
74
+ def db(skip_reconcile=false)
75
+ if @db && @reconcile_complete
76
+ return @db
77
+ end
78
+
79
+ @db_mutex.synchronize do
80
+ unless @db
81
+ begin
82
+ @adaptor_name = connect_to_db
83
+
84
+ @db.test_connection
85
+ rescue Sequel::DatabaseConnectionError => e
86
+ if e.message =~ /does not exist/
87
+ create_missing_database
88
+ else
89
+ raise
90
+ end
91
+
92
+ rescue Sequel::AdapterNotFound => e
93
+ missing_gem = e.message.match(/LoadError[:] cannot load such file -- ([^ ]+)$/)
94
+ if missing_gem
95
+ helpers = {
96
+ 'postgres' => "gem 'pg', '~> 0.18.2'\ngem 'pg_json', '~> 0.1.29'",
97
+ 'sqlite3' => "gem 'sqlite3'",
98
+ 'mysql2' => "gem 'mysql2'"
99
+ }
100
+
101
+ adaptor_name = missing_gem[1]
102
+ if (helper = helpers[adaptor_name])
103
+ helper = "\nMake sure you have the following in your gemfile:\n" + helper + "\n\n"
104
+ else
105
+ helper = ''
106
+ end
107
+ raise NameError.new("LoadError: cannot load the #{adaptor_name} gem.#{helper}")
108
+ else
109
+ raise
110
+ end
111
+ end
112
+
113
+ if @adaptor_name == 'postgres'
114
+ @db.extension :pg_json
115
+ # @db.extension :pg_json_ops
116
+ end
117
+
118
+ if ENV['LOG_SQL']
119
+ @db.loggers << Volt.logger
120
+ end
121
+ end
122
+
123
+ # Don't try to reconcile until the app is booted
124
+ reconcile! if !skip_reconcile && @app_booted
125
+ end
126
+
127
+ @db
128
+ end
129
+
130
+ # @param - a string URI, or a Hash of options
131
+ # @param - the adaptor name
132
+ def connect_uri_or_options
133
+ # check to see if a uri was specified
134
+ conf = Volt.config
135
+
136
+ uri = conf.db && conf.db.uri
137
+
138
+ if uri
139
+ adaptor = uri[/^([a-z]+)/]
140
+
141
+ return uri, adaptor
142
+ else
143
+ adaptor = (conf.db && conf.db.adapter || 'sqlite').to_s
144
+ if adaptor == 'sqlite'
145
+ # Make sure we have a config/db folder
146
+ FileUtils.mkdir_p('config/db')
147
+ end
148
+
149
+ data = Volt.config.db.to_h.symbolize_keys
150
+ data[:database] ||= "config/db/#{Volt.env.to_s}.db"
151
+ data[:adapter] ||= adaptor
152
+
153
+ return data, adaptor
154
+ end
155
+ end
156
+
157
+ def connect_to_db
158
+ uri_opts, adaptor = connect_uri_or_options
159
+
160
+ @db = Sequel.connect(uri_opts)
161
+
162
+ if adaptor == 'sqlite'
163
+ @db.set_integer_booleans
164
+ end
165
+
166
+ adaptor
167
+ end
168
+
169
+ # In order to create the database, we have to connect first witout the
170
+ # database.
171
+ def create_missing_database
172
+ @db.disconnect
173
+ uri_opts, adaptor = connect_uri_or_options
174
+
175
+ if uri_opts.is_a?(String)
176
+ # A uri
177
+ *uri_opts, db_name = uri_opts.split('/')
178
+ uri_opts = uri_opts.join('/')
179
+ else
180
+ # Options hash
181
+ db_name = uri_opts.delete(:database)
182
+ end
183
+
184
+ @db = Sequel.connect(uri_opts)
185
+
186
+ # No database, try to create it
187
+ log "Database does not exist, attempting to create database #{db_name}"
188
+ @db.run("CREATE DATABASE #{db_name};")
189
+ @db.disconnect
190
+ @db = nil
191
+
192
+ connect_to_db
193
+ end
194
+
195
+ def reconcile!
196
+ unless @skip_reconcile
197
+ Sql::Reconcile.new(self, @db).reconcile!
198
+ end
199
+
200
+ @reconcile_complete = true
201
+ end
202
+
203
+ # Mark that a model changed and we need to rerun reconcile next time the
204
+ # db is accessed.
205
+ def invalidate_reconcile!
206
+ @reconcile_complete = false
207
+ end
208
+
209
+ # Used when creating a class that you don't want to reconcile after
210
+ def skip_reconcile
211
+ @skip_reconcile = true
212
+
213
+ begin
214
+ yield
215
+ ensure
216
+ @skip_reconcile = false
217
+ end
218
+ end
219
+
220
+ # Called when the db gets reset (from specs usually)
221
+ def reset!
222
+ Sql::Reconcile.new(self, @db).reset!
223
+ end
224
+
225
+ def insert(collection, values)
226
+ values = pack_values(collection, values)
227
+
228
+ db.from(collection).insert(values)
229
+ end
230
+
231
+ def update(collection, values)
232
+ values = pack_values(collection, values)
233
+
234
+ # Find the original so we can update it
235
+ table = db.from(collection)
236
+
237
+ # TODO: we should move this to a real upsert
238
+ begin
239
+ table.insert(values)
240
+ log(table.insert_sql(values))
241
+ rescue Sequel::UniqueConstraintViolation => e
242
+ # Already a record, update
243
+ id = values[:id]
244
+ log(table.where(id: id).update_sql(values))
245
+ table.where(id: id).update(values)
246
+ end
247
+
248
+ nil
249
+ end
250
+
251
+ def query(collection, query)
252
+ allowed_methods = %w(where where_with_block offset limit count)
253
+
254
+ result = db.from(collection.to_sym)
255
+
256
+ query.each do |query_part|
257
+ method_name, *args = query_part
258
+
259
+ unless allowed_methods.include?(method_name.to_s)
260
+ fail "`#{method_name}` is not part of a valid query"
261
+ end
262
+
263
+ # Symbolize Keys
264
+ args = args.map do |arg|
265
+ if arg.is_a?(Hash)
266
+ arg = self.class.nested_symbolize_keys(arg)
267
+ end
268
+ arg
269
+ end
270
+
271
+ if method_name == :where_with_block
272
+ # Where calls with block are handled differently. We have to replay
273
+ # the query that was captured on the client with QueryIdentifier
274
+
275
+ # Grab the AST that was generated from the block call on the client.
276
+ block_ast = args.pop
277
+ args = self.class.move_to_db_types(args)
278
+
279
+ result = result.where(*args) do |ident|
280
+ Sql::WhereCall.new(ident).call(block_ast)
281
+ end
282
+ else
283
+ args = self.class.move_to_db_types(args)
284
+ result = result.send(method_name, *args)
285
+ end
286
+ end
287
+
288
+ if result.respond_to?(:all)
289
+ log(result.sql)
290
+ result = result.all.map do |hash|
291
+ # Volt expects symbol keys
292
+ hash.symbolize_keys
293
+ end#.tap {|v| puts "QUERY: " + v.inspect }
294
+
295
+ # Unpack extra values
296
+ result = unpack_values!(result)
297
+ end
298
+
299
+ result
300
+ end
301
+
302
+ def delete(collection, query)
303
+ query = self.class.move_to_db_types(query)
304
+ db.from(collection).where(query).delete
305
+ end
306
+
307
+ # remove the collection entirely
308
+ def drop_collection(collection)
309
+ db.drop_collection(collection)
310
+ end
311
+
312
+ def drop_database
313
+ RootModels.clear_temporary
314
+
315
+ # Drop all tables
316
+ db(true).drop_table(*db.tables)
317
+
318
+ RootModels.model_classes.each do |model_klass|
319
+ model_klass.reconciled = false
320
+ end
321
+
322
+ invalidate_reconcile!
323
+ end
324
+
325
+
326
+ def self.move_to_db_types(values)
327
+ values = nested_symbolize_keys(values)
328
+
329
+ values = Volt::DataTransformer.transform(values, false) do |value|
330
+ if defined?(VoltTime) && value.is_a?(VoltTime)
331
+ value.to_time
332
+ elsif value.is_a?(Symbol)
333
+ # Symbols get turned into strings
334
+ value.to_s
335
+ else
336
+ value
337
+ end
338
+ end
339
+
340
+ values
341
+ end
342
+
343
+ private
344
+
345
+ def self.nested_symbolize_keys(values)
346
+ DataTransformer.transform_keys(values) do |key|
347
+ key.to_sym
348
+ end
349
+ end
350
+
351
+ # Take the values and symbolize them, and also remove any values that
352
+ # aren't going in fields and move them into extra.
353
+ #
354
+ # Then change VoltTime's to Time for Sequel
355
+ def pack_values(collection, values)
356
+ values = self.class.move_to_db_types(values)
357
+
358
+ klass = Volt::Model.class_at_path([collection])
359
+ # Find any fields in values that aren't defined with a ```field```,
360
+ # and put them into extra.
361
+ extra = {}
362
+ values = values.select do |key, value|
363
+ if klass.fields[key] || key == :id
364
+ # field is defined, keep in values
365
+ true
366
+ else
367
+ # field does not exist, move to extra
368
+ extra[key] = value
369
+
370
+ false
371
+ end
372
+ end
373
+
374
+ # Copy the extras into the values
375
+ values[:extra] = serialize(extra) if extra.present?
376
+
377
+ # Volt.logger.info("Insert into #{collection}: #{values.inspect}")
378
+
379
+ values
380
+ end
381
+
382
+ # Loop through the inputs array and change values in place to be unpacked.
383
+ # Unpacking means moving the extra field out and into the main fields.
384
+ #
385
+ # Then transform Time to VoltTime
386
+ def unpack_values!(inputs)
387
+ values = inputs.each do |values|
388
+ extra = values.delete(:extra)
389
+
390
+ if extra
391
+ extra = deserialize(extra)
392
+ self.class.nested_symbolize_keys(extra.to_h).each_pair do |key, new_value|
393
+ unless values[key]
394
+ values[key] = new_value
395
+ end
396
+ end
397
+ end
398
+ end
399
+
400
+ values = Volt::DataTransformer.transform(values) do |value|
401
+ if defined?(VoltTime) && value.is_a?(Time)
402
+ value = VoltTime.from_time(value)
403
+ else
404
+ value
405
+ end
406
+ end
407
+
408
+ values
409
+ end
410
+
411
+ end
412
+
413
+
414
+ # Specific adaptors for each database
415
+ class PostgresAdaptorServer < SqlAdaptorServer
416
+ def serialize(extra)
417
+ Sequel.pg_json(extra)
418
+ end
419
+
420
+ def deserialize(extra)
421
+ extra
422
+ end
423
+ end
424
+
425
+ class SqliteAdaptorServer < SqlAdaptorServer
426
+ def serialize(extra)
427
+ JSON.dump(extra)
428
+ end
429
+
430
+ def deserialize(extra)
431
+ JSON.parse(extra)
432
+ end
433
+ end
434
+
435
+ class MysqlAdaptorServer < SqlAdaptorServer
436
+ end
437
+ end
438
+ end
@@ -0,0 +1,13 @@
1
+ module Volt
2
+ module Sql
3
+ module SqlLogger
4
+
5
+ def log(msg)
6
+ if ENV['LOG_SQL']
7
+ Volt.logger.log_with_color(msg, :cyan)
8
+ end
9
+ end
10
+
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ # Reopen the store class and tell it not to allow "on the fly" collections.
2
+ module Volt
3
+ module Persistors
4
+ class Store
5
+ def on_the_fly_collections?
6
+ false
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,123 @@
1
+ # Table Reconcile is responsible for migrating a table from the database state
2
+ # to the model state.
3
+ require 'sql/lib/sql_logger'
4
+ require 'sql/lib/field_updater'
5
+ require 'sql/lib/index_updater'
6
+
7
+ module Volt
8
+ module Sql
9
+ class TableReconcile
10
+ include SqlLogger
11
+
12
+ attr_reader :field_updater
13
+
14
+ def initialize(adaptor, db, model_class)
15
+ @model_class = model_class
16
+ @adaptor = adaptor
17
+ @db = db
18
+ @field_updater = FieldUpdater.new(@db, self)
19
+ end
20
+
21
+ def run
22
+ table_name = @model_class.collection_name
23
+
24
+ ensure_table(table_name)
25
+
26
+ update_fields(@model_class, table_name)
27
+
28
+ IndexUpdater.new(@db, @model_class, table_name)
29
+
30
+ @model_class.reconciled = true
31
+ end
32
+
33
+ # Create an empty table if one does not exist
34
+ def ensure_table(table_name)
35
+ # Check if table exists
36
+ if !@db.tables || !@db.tables.include?(table_name)
37
+ log("Creating Table #{table_name}")
38
+ adaptor_name = @adaptor.adaptor_name
39
+ @db.create_table(table_name) do
40
+ # guid id
41
+ column :id, String, :unique => true, :null => false, :primary_key => true
42
+
43
+ # When using underscore notation on a field that does not exist, the
44
+ # data will be stored in extra.
45
+ if adaptor_name == 'postgres'
46
+ # Use jsonb
47
+ column :extra, 'json'
48
+ else
49
+ column :extra, String
50
+ end
51
+ end
52
+ # TODO: there's some issue with @db.schema and no clue why, but I
53
+ # have to run this again to get .schema to work later.
54
+ @db.tables
55
+ end
56
+ end
57
+
58
+
59
+ def sequel_class_and_opts_from_db(db_field)
60
+ vclasses, vopts = Helper.klasses_and_options_from_db(db_field)
61
+ return Helper.column_type_and_options_for_sequel(vclasses, vopts)
62
+ end
63
+
64
+ # Pulls the db_fields out of sequel
65
+ def db_fields_for_table(table_name)
66
+ db_fields = {}
67
+ result = @db.schema(table_name).each do |col|
68
+ db_fields[col[0].to_sym] = col[1]
69
+ end
70
+
71
+ db_fields
72
+ end
73
+
74
+ def update_fields(model_class, table_name)
75
+ if (fields = model_class.fields)
76
+ db_fields = db_fields_for_table(table_name)
77
+
78
+ db_fields.delete(:id)
79
+ db_fields.delete(:extra)
80
+
81
+ orphan_fields = db_fields.keys - fields.keys
82
+ new_fields = fields.keys - db_fields.keys
83
+
84
+ # If a single field was renamed, we can auto-migrate
85
+ if (orphan_fields.size == 1 && new_fields.size == 1)
86
+ from_name = orphan_fields[0]
87
+ to_name = new_fields[0]
88
+ auto_migrate_field_rename(table_name, from_name, to_name)
89
+
90
+ # Move in start fields
91
+ db_fields[to_name] = db_fields.delete(from_name)
92
+ end
93
+
94
+ if new_fields.size == 0 && orphan_fields.size > 0
95
+ # one or more fields were removed
96
+ orphan_fields.each do |field_name|
97
+ @field_updater.auto_migrate_remove_field(table_name, field_name, db_fields[field_name])
98
+ end
99
+ end
100
+
101
+
102
+ if orphan_fields.size == 0
103
+
104
+ fields.each do |name, klass_and_opts|
105
+ name = name.to_sym
106
+ klasses, opts = klass_and_opts
107
+
108
+ # Get the original state for the field
109
+ db_field = db_fields.delete(name)
110
+
111
+ @field_updater.update_field(model_class, table_name, db_field, name, klasses, opts)
112
+ end
113
+
114
+ # remove any fields we didn't see in the models
115
+ end
116
+ else
117
+ # Either >1 orphaned fields, or more than one new fields
118
+ raise "Could not auto migrate #{table_name}"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,64 @@
1
+ # Queries on the client side can be captured using block syntax. On the client
2
+ # a Volt::QueryIdentifier is passed to the block. It will create a AST for
3
+ # queries which is sent.
4
+ #
5
+ # Sql::WhereCall can reply the query to the underlying database engine (in
6
+ # this case, sequel)
7
+
8
+ module Volt
9
+ module Sql
10
+ class WhereCall
11
+ VALID_METHODS = ['&', '|', '~', '>', '<', '>=', '<=' , '=~', '!~']
12
+ def initialize(ident)
13
+ @ident = ident
14
+ end
15
+
16
+ def call(ast)
17
+ walk(ast)
18
+ end
19
+
20
+ def walk(ast)
21
+ if ast.is_a?(Array) && !ast.is_a?(Sequel::SQL::Identifier)
22
+ op = ast.shift
23
+
24
+ case op
25
+ when 'c'
26
+ return op_call(*ast)
27
+ when 'a'
28
+ # We popped off the 'a', so we just return the array
29
+ return ast
30
+ else
31
+ raise "invalid op: #{op.inspect} - #{ast.inspect} - #{ast.is_a?(Array).inspect}"
32
+ end
33
+ else
34
+ # Not an operation, return
35
+ return ast
36
+ end
37
+ end
38
+
39
+ def op_call(self_obj, method_name, *args)
40
+ if self_obj == 'ident'
41
+ self_obj = @ident
42
+ end
43
+
44
+ # walk on the self obj
45
+ self_obj = walk(self_obj)
46
+
47
+ # Method name security checks
48
+ case method_name
49
+ when 'send'
50
+ raise "Send is not supported in queries"
51
+ end
52
+
53
+ if method_name !~ /^[a-zA-Z0-9_]+$/ && !VALID_METHODS.include?(method_name)
54
+ raise "Only method names matching /[a-zA-Z0-9_]/ are allowed from client side queries (called `#{method_name}`)"
55
+ end
56
+
57
+ walked_args = args.map {|arg| walk(arg) }
58
+
59
+ # We have to use __send__ because send is handled differently
60
+ self_obj.__send__(method_name, *walked_args)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,2 @@
1
+ <:Body>
2
+ -- component --
Binary file
data/config/db/test.db ADDED
Binary file
@@ -0,0 +1,5 @@
1
+ module Volt
2
+ module Sql
3
+ VERSION = '0.0.1'
4
+ end
5
+ end
data/lib/volt/sql.rb ADDED
@@ -0,0 +1,27 @@
1
+ # If you need to require in code in the gem's app folder, keep in mind that
2
+ # the app is not on the load path when the gem is required. Use
3
+ # app/{gemname}/config/initializers/boot.rb to require in client or server
4
+ # code.
5
+ #
6
+ # Also, in volt apps, you typically use the lib folder in the
7
+ # app/{componentname} folder instead of this lib folder. This lib folder is
8
+ # for setting up gem code when Bundler.require is called. (or the gem is
9
+ # required.)
10
+ #
11
+ # If you need to configure volt in some way, you can add a Volt.configure block
12
+ # in this file.
13
+
14
+
15
+ Volt.configure do |config|
16
+ # Set the datastore to sql
17
+ config.public.datastore_name = 'sql'
18
+
19
+ # Include the sql component on the client
20
+ config.default_components << 'sql'
21
+ end
22
+
23
+ module Volt
24
+ module Sql
25
+ # Your code goes here...
26
+ end
27
+ end