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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 918bb2e8b15738d1e2d2b80cba4c09cfa24f26a8
4
+ data.tar.gz: a52245cae20149dec9fa0033c92ca382ef800eb9
5
+ SHA512:
6
+ metadata.gz: 1383480587bfd8b8eed8ebcde8c56ebd68163366622fe9fb63c7d61afa52cea5bad104c6f0ff217bcaaecbd7782f7b5dbcdc77b9b10af66f46f981df6b1d182d
7
+ data.tar.gz: e52a71cfb0f1a3941fbd67f9ae62ba01e934b90a27f98440c57c27655dbdb95212af6f99764254f4d776aac4e11cbfb88a59bb1863939becb6e81f85e46b32c8
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in volt-mongo.gemspec
4
+ gemspec
5
+
6
+ gem 'volt', path: '/Users/ryanstout/Sites/volt/volt'
7
+ gem 'rspec'
8
+ gem 'sqlite3'
9
+ gem 'pg', '~> 0.18.2'
10
+ gem 'pg_json', '~> 0.1.29'
data/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # volt-sql
2
+
3
+ **Note**: This is still a work in process. We'll update this when the final version is out.
4
+
5
+ This gem provides postgres support for volt. Just include it in your gemfile and the gem will do the rest. It is included in new projects by default (currently).
6
+
7
+ ```
8
+ gem 'volt-sql'
9
+ gem 'pg', '~> 0.18.2'
10
+ gem 'pg_json', '~> 0.1.29'
11
+ '
12
+ ```
13
+
14
+
15
+ ## Big Thanks
16
+
17
+ First off, I wanted to say a big thanks to @jeremyevans for his hard work on Sequel, without which, this gem would not be possible.
18
+
19
+ ## How migrations work:
20
+
21
+ When an app boots in dev mode, or a file is changed in dev mode, and a new field is detected, the following happens:
22
+
23
+ if there are no orphan fields (fields that exist in the db, but not in the Models class):
24
+ - the field is added
25
+
26
+ - If there is a single orphaned field and a single added field in the model:
27
+ - a migration is generated to rename the field
28
+ - the migration is run
29
+
30
+ - If there are multiple orphans or fields added
31
+ - volt warns you and makes you deal with it (by creating migrations)
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1 @@
1
+ # Component dependencies
@@ -0,0 +1,5 @@
1
+ require 'sql/lib/sql_adaptor_client'
2
+ if RUBY_PLATFORM != 'opal'
3
+ require 'sql/lib/sql_adaptor_server'
4
+ end
5
+ require 'sql/lib/store_persistor'
@@ -0,0 +1 @@
1
+ # Component routes
@@ -0,0 +1,5 @@
1
+ require 'sql/config/initializers/boot'
2
+ module Sql
3
+ class MainController < Volt::ModelController
4
+ end
5
+ end
@@ -0,0 +1,103 @@
1
+ # The FieldUpdater class is responsible for adding or updating a field, either
2
+ # by calling sequel directly, or by generating a migration, then running it.
3
+ require 'sql/lib/sql_logger'
4
+
5
+ module Volt
6
+ module Sql
7
+ class FieldUpdater
8
+ include SqlLogger
9
+
10
+ def initialize(db, table_reconcile)
11
+ @db = db
12
+ @table_reconcile = table_reconcile
13
+ end
14
+
15
+ # Update a field (or create it)
16
+ def update_field(model_class, table_name, db_field, column_name, klasses, opts)
17
+ sequel_class, sequel_opts = Helper.column_type_and_options_for_sequel(klasses, opts)
18
+
19
+ # Check if column exists
20
+ if !db_field
21
+
22
+ log("Add field #{column_name} to #{table_name}")
23
+ # Column does not exist, add it.
24
+ # Make sure klass matches
25
+ @db.add_column(table_name, column_name, sequel_class, sequel_opts)
26
+ else
27
+ db_class, db_opts = @table_reconcile.sequel_class_and_opts_from_db(db_field)
28
+
29
+ if db_class != sequel_class || db_opts != sequel_opts
30
+
31
+ # Data type has changed, migrate
32
+ up_code = []
33
+ down_code = []
34
+
35
+ if db_opts != sequel_opts
36
+ # Fetch allow_null, keeping in mind it defaults to true
37
+ db_null = db_opts.fetch(:allow_null, true)
38
+ sequel_null = sequel_opts.fetch(:allow_null, true)
39
+
40
+ if db_null != sequel_null
41
+ # allow null changed
42
+ if sequel_null
43
+ up_code << "set_column_allow_null #{table_name.inspect}, #{column_name.inspect}"
44
+ down_code << "set_column_not_null #{table_name.inspect}, #{column_name.inspect}"
45
+ else
46
+ up_code << "set_column_not_null #{table_name.inspect}, #{column_name.inspect}"
47
+ down_code << "set_column_allow_null #{table_name.inspect}, #{column_name.inspect}"
48
+ end
49
+
50
+ db_opts.delete(:allow_null)
51
+ sequel_opts.delete(:allow_null)
52
+ end
53
+ end
54
+
55
+
56
+ if db_class != sequel_class || db_opts != sequel_opts
57
+ up_code << "set_column_type #{table_name.inspect}, #{column_name.inspect}, #{sequel_class}, #{sequel_opts.inspect}"
58
+ down_code << "set_column_type #{table_name.inspect}, #{column_name.inspect}, #{db_class}, #{db_opts.inspect}"
59
+ end
60
+
61
+
62
+ if up_code.present?
63
+ generate_and_run("column_change_#{table_name.to_s.gsub('/', '_')}_#{column_name}", up_code.join("\n"), down_code.join("\n"))
64
+ end
65
+
66
+ # TODO: Improve message
67
+ # raise "Data type changed, can not migrate field #{name} from #{db_field.inspect} to #{klass.inspect}"
68
+ end
69
+ end
70
+
71
+ end
72
+
73
+
74
+ def auto_migrate_field_rename(table_name, from_name, to_name)
75
+ log("Rename #{from_name} to #{to_name} on table #{table_name}")
76
+
77
+ name = "rename_#{table_name}_#{from_name}_to_#{to_name}"
78
+ up_code = "rename_column #{table_name.inspect}, #{from_name.inspect}, #{to_name.inspect}"
79
+ down_code = "rename_column #{table_name.inspect}, #{to_name.inspect}, #{from_name.inspect}"
80
+ generate_and_run(name, up_code, down_code)
81
+ end
82
+
83
+ def auto_migrate_remove_field(table_name, column_name, db_field)
84
+ log("Remove #{column_name} from table #{table_name}")
85
+
86
+ name = "remove_#{table_name}_#{column_name}"
87
+ up_code = "drop_column #{table_name.inspect}, #{column_name.inspect}"
88
+
89
+ sequel_class, sequel_options = @table_reconcile.sequel_class_and_opts_from_db(db_field)
90
+ down_code = "add_column #{table_name.inspect}, #{column_name.inspect}, #{sequel_class}, #{sequel_options.inspect}"
91
+ generate_and_run(name, up_code, down_code)
92
+ end
93
+
94
+ # private
95
+ def generate_and_run(name, up_code, down_code)
96
+ path = Volt::Sql::MigrationGenerator.create_migration(name, up_code, down_code)
97
+
98
+ Volt::MigrationRunner.new(@db).run_migration(path, :up)
99
+ end
100
+
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,121 @@
1
+ # A few pure functions for converting between volt types/options and sequel
2
+ # type/options
3
+
4
+ module Volt
5
+ module Sql
6
+ module Helper
7
+
8
+
9
+ # This method takes in info from the db schema and returns the volt field
10
+ # klass and options that would have been used to create it.
11
+ #
12
+ # @returns [Array of klasses, Hash of options]
13
+ def self.klasses_and_options_from_db(db_field)
14
+ klasses = []
15
+ options = {}
16
+
17
+ # merge values based on map (options key from db_key)
18
+ {
19
+ :text => :text,
20
+ :size => :max_length,
21
+ :nil => :allow_null,
22
+ :default => :ruby_default
23
+ }.each_pair do |opts_key, db_key|
24
+ options[opts_key] = db_field[db_key] if db_field.has_key?(db_key)
25
+ end
26
+
27
+ options.delete(:default) if options[:default] == nil
28
+
29
+ db_type = db_field[:db_type].to_sym
30
+
31
+ case db_field[:type]
32
+ when :string
33
+ klasses << String
34
+ when :datetime
35
+ klasses << VoltTime
36
+ when :boolean
37
+ klasses << Volt::Boolean
38
+ when :float
39
+ klasses << Float
40
+ else
41
+ case db_type
42
+ when :text
43
+ when :string
44
+ klasses << String
45
+ when :numeric
46
+ klasses << Numeric
47
+ when :integer
48
+ klasses << Fixnum
49
+ end
50
+ end
51
+
52
+ if klasses.size == 0
53
+ raise "Could not match database type #{db_type} in #{db_field.inspect}"
54
+ end
55
+
56
+ # Default is to allow nil
57
+ unless options[:nil] == false
58
+ klasses << NilClass
59
+ end
60
+ options.delete(:nil)
61
+
62
+ return klasses, options
63
+ end
64
+
65
+
66
+ # Takes in the klass and options specified on the model or an add_column
67
+ # and returns the correct klass/options for add_column in sequel.
68
+ def self.column_type_and_options_for_sequel(klasses, options)
69
+ options = options.dup
70
+
71
+ # Remove from start fields
72
+ klasses ||= [String, NilClass]
73
+
74
+ allow_nil = klasses.include?(NilClass)
75
+ klasses = klasses.reject {|klass| klass == NilClass }
76
+
77
+ if klasses.size > 1
78
+ raise MultiTypeException, 'the sql adaptor only supports a single type (or NilClass) for each field.'
79
+ end
80
+
81
+ klass = klasses.first
82
+
83
+ if options.has_key?(:nil)
84
+ options[:allow_null] = options.delete(:nil)
85
+ else
86
+ options[:allow_null] = allow_nil
87
+ end
88
+
89
+ if klass == String
90
+ # If no length restriction, make it text
91
+ if options[:size]
92
+ if options[:size] > 255
93
+ # Make the field text
94
+ options.delete(:size)
95
+ options[:text] = true
96
+ end
97
+ else
98
+ options[:text] = true
99
+ end
100
+ elsif klass == VoltTime
101
+ klass = Time
102
+ elsif klass == Volt::Boolean || klass == TrueClass || klass == FalseClass
103
+ klass = TrueClass # what sequel uses for booleans
104
+ end
105
+
106
+ return klass, options
107
+ end
108
+
109
+ # When asking for indexes on a table, the deferrable option will show
110
+ # as nil if it wasn't set, we remove this to normalize it to the volt
111
+ # options.
112
+ def self.normalized_indexes_from_table(db, table_name)
113
+ db.indexes(table_name).map do |name, options|
114
+ # Remove the deferrable that defaults to false (nil)
115
+ options = options.reject {|key, value| key == :deferrable && !value }
116
+ [name, options]
117
+ end.to_h
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,82 @@
1
+ # The IndexUpdater looks at the current indexes on a table and those in the db
2
+ # and reconciles them. Since indexes are immutable, this means dropping any
3
+ # that don't match and creating new ones. (simple) This also means we don't
4
+ # need any migrations.
5
+
6
+ require 'sql/lib/sql_logger'
7
+ require 'sql/lib/helper'
8
+
9
+ module Volt
10
+ module Sql
11
+ class IndexUpdater
12
+ include SqlLogger
13
+
14
+ def initialize(db, model_class, table_name)
15
+ @db = db
16
+ @table_name = table_name
17
+
18
+ model_indexes = model_class.indexes
19
+ db_indexes = Helper.normalized_indexes_from_table(@db, table_name)
20
+
21
+ model_indexes.each_pair do |name, options|
22
+ # See if we have a matching columns/options
23
+ if db_indexes[name] == options
24
+ # Matches, ignore it
25
+ db_indexes.delete(name)
26
+ else
27
+ # Something changed, if a db_index for the name exists,
28
+ # delete it, because the options changed
29
+ if (db_opts = db_indexes[name])
30
+ # Drop the index, drop it from the liast of db_indexes
31
+ drop_index(name, db_opts)
32
+ db_indexes.delete(name)
33
+ end
34
+
35
+ # Create the new index
36
+ add_index(name, options)
37
+ end
38
+ end
39
+
40
+ # drop any remaining db_indexes, because they are no longer defined in
41
+ # the model
42
+ db_indexes.each do |name, options|
43
+ drop_index(name, options)
44
+ end
45
+
46
+ @db.indexes(table_name)
47
+ end
48
+
49
+
50
+ def drop_index(name, options)
51
+ columns, options = columns_and_options(name, options)
52
+ log("drop index on #{columns.inspect}, #{options.inspect}")
53
+
54
+ @db.alter_table(@table_name) do
55
+ drop_index(columns, options.select {|k,v| k == :name })
56
+ end
57
+ end
58
+
59
+ def add_index(name, options)
60
+ columns, options = columns_and_options(name, options)
61
+ log("add index on #{columns.inspect}, #{options.inspect}")
62
+ @db.alter_table(@table_name) do
63
+ add_index(columns, options)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # Convert to the columns, options(with name) format used by sequel for
70
+ # add_index/drop_index
71
+ def columns_and_options(name, options)
72
+ options = options.dup
73
+ columns = options.delete(:columns)
74
+
75
+ options[:name] = name
76
+
77
+ return columns, options
78
+ end
79
+
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,52 @@
1
+ # Add database methods for the migration class
2
+ module Volt
3
+ class Migration
4
+ def initialize(db=nil)
5
+ @db = db || Volt.current_app.database.db
6
+ end
7
+
8
+ def add_column(table_name, column_name, klasses, options={})
9
+ sequel_type, sequel_options = Helper.column_type_and_options_for_sequel(klasses, options)
10
+ @db.alter_table(table_name) do
11
+ add_column(column_name, sequel_type, sequel_options)
12
+ end
13
+ end
14
+
15
+ def rename_column(table_name, from, to, options={})
16
+ # TODO: add options check
17
+ @db.alter_table(table_name) do
18
+ rename_column from, to
19
+ end
20
+ end
21
+
22
+ def drop_column(table_name, column_name)
23
+ @db.alter_table(table_name) do
24
+ drop_column column_name
25
+ end
26
+ end
27
+
28
+ def set_column_type(table_name, column_name, type, options={})
29
+ @db.alter_table(table_name) do
30
+ set_column_type(column_name, type, options)
31
+ end
32
+ end
33
+
34
+ def set_column_allow_null(table_name, column_name)
35
+ @db.alter_table(table_name) do
36
+ set_column_allow_null(column_name)
37
+ end
38
+ end
39
+
40
+ def set_column_not_null(table_name, column_name)
41
+ @db.alter_table(table_name) do
42
+ set_column_not_null(column_name)
43
+ end
44
+ end
45
+
46
+ def set_column_default(table_name, column_name, default)
47
+ @db.alter_table(table_name) do
48
+ set_column_default(column_name, default)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,44 @@
1
+ # Run and create migrations programatically.
2
+ require 'fileutils'
3
+
4
+ module Volt
5
+ module Sql
6
+ module MigrationGenerator
7
+ def self.create_migration(name, up_content, down_content)
8
+ timestamp = Time.now.to_i
9
+ file_name = "#{timestamp}_#{name.underscore}"
10
+ class_name = name.camelize
11
+ output_file = "#{Dir.pwd}/config/db/migrations/#{file_name}.rb"
12
+
13
+ FileUtils.mkdir_p(File.dirname(output_file))
14
+
15
+ content = <<-END.gsub(/^ {8}/, '')
16
+ class #{class_name} < Volt::Migration
17
+ def up
18
+ #{indent_string(up_content, 4)}
19
+ end
20
+
21
+ def down
22
+ #{indent_string(down_content, 4)}
23
+ end
24
+ end
25
+ END
26
+
27
+ File.open(output_file, 'w') {|f| f.write(content) }
28
+
29
+ # return the path to the file
30
+ output_file
31
+ end
32
+
33
+ def self.indent_string(string, count)
34
+ string.split("\n").map.with_index do |line,index|
35
+ if index == 0
36
+ string
37
+ else
38
+ (' ' * count) + string
39
+ end
40
+ end.join("\n")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,51 @@
1
+ require 'sql/lib/table_reconcile'
2
+ require 'uri'
3
+
4
+ module Volt
5
+ # Add reconcile! directly to the model (on the server)
6
+ class Model
7
+ class_attribute :reconciled
8
+ end
9
+
10
+ class MultiTypeException < Exception
11
+
12
+ end
13
+
14
+ module Sql
15
+ class Reconcile
16
+ attr_reader :db
17
+ def initialize(adaptor, db)
18
+ @adaptor = adaptor
19
+ @db = db
20
+ end
21
+
22
+ # reconcile takes the database from its current state to the state defined
23
+ # in the model classes with the field helper
24
+ def reconcile!
25
+ Volt::RootModels.model_classes.each do |model_class|
26
+ TableReconcile.new(@adaptor, @db, model_class).run
27
+ end
28
+
29
+ # After the initial reconcile!, we add a listener for any new models
30
+ # created, so we can reconcile them (in specs mostly)
31
+ reset!
32
+ @@listener = RootModels.on('model_created') do |model_class|
33
+ # We do a full invalidate and wait for the next db access, because the
34
+ # model_created gets called before the class is actually fully defined.
35
+ # (ruby inherited limitation)
36
+ @adaptor.invalidate_reconcile!
37
+ end
38
+ end
39
+
40
+
41
+ # Called to clear the listener
42
+ def reset!
43
+ if defined?(@@listener) && @@listener
44
+ @@listener.remove
45
+ @@listener = nil
46
+ end
47
+ end
48
+
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,110 @@
1
+ module Volt
2
+ class DataStore
3
+ class SqlAdaptorClient < BaseAdaptorClient
4
+ data_store_methods :where, :offset, :skip, :order, :limit, :count, :includes
5
+
6
+ module SqlArrayStore
7
+ def skip(*args)
8
+ add_query_part(:offset, *args)
9
+ end
10
+
11
+ # Count without arguments or a block makes its own query to the backend.
12
+ # If you pass an arg or block, it will run ```all``` on the Cursor, then
13
+ # run a normal ruby ```.count``` on it, passing the args.
14
+ def count(*args, &block)
15
+ if args || block
16
+ @model.reactive_count(*args, &block)
17
+ else
18
+ cursor = add_query_part(:count)
19
+
20
+ cursor.persistor.value
21
+ end
22
+ end
23
+ end
24
+
25
+ # Due to the way define_method works, we need to remove the generated
26
+ # methods from data_store_methods before we over-ride them.
27
+ Volt::Persistors::ArrayStore.send(:remove_method, :skip)
28
+ Volt::Persistors::ArrayStore.send(:remove_method, :count)
29
+
30
+ # include sql's methods on ArrayStore
31
+ Volt::Persistors::ArrayStore.send(:include, SqlArrayStore)
32
+
33
+ module SqlArrayModel
34
+ def dataset
35
+ Volt::DataStore.fetch(Volt.current_app).db.from(collection_name)
36
+ end
37
+ end
38
+
39
+ Volt::ArrayModel.send(:include, SqlArrayModel)
40
+
41
+
42
+ # In the volt query dsl (and sql), there's a lot of ways to express the
43
+ # same query. Its better for performance however if queries can be
44
+ # uniquely identified. To make that happen, we normalize queries.
45
+ def self.normalize_query(query)
46
+ # query = convert_wheres_to_block(query)
47
+ query = merge_wheres_and_move_to_front(query)
48
+
49
+ query = reject_offset_zero(query)
50
+
51
+ query
52
+ end
53
+
54
+ # Where's can use either a hash arg, or a block. If the where has a hash
55
+ # arg, we convert it to block style, so it can be unified.
56
+ def self.convert_wheres_to_block(query)
57
+ wheres = []
58
+
59
+ query.reject! do |query_part|
60
+ if query_part[0] == 'where'
61
+ wheres << query_part
62
+ # reject
63
+ true
64
+ else
65
+ # keep
66
+ false
67
+ end
68
+ end
69
+ end
70
+
71
+ def self.merge_wheres_and_move_to_front(query)
72
+ # Map first parts to string
73
+ query = query.map { |v| v[0] = v[0].to_s; v }
74
+ has_where = query.find { |v| v[0] == 'find' }
75
+
76
+ # if has_find
77
+ # # merge any finds
78
+ # merged_find_query = {}
79
+ # query = query.reject do |query_part|
80
+ # if query_part[0] == 'find'
81
+ # # on a find, merge into finds
82
+ # find_query = query_part[1]
83
+ # merged_find_query.merge!(find_query) if find_query
84
+
85
+ # # reject
86
+ # true
87
+ # else
88
+ # false
89
+ # end
90
+ # end
91
+
92
+ # # Add finds to the front
93
+ # query.insert(0, ['find', merged_find_query])
94
+ # else
95
+ # # No find was done, add it in the first position
96
+ # query.insert(0, ['find'])
97
+ # end
98
+
99
+ query
100
+ end
101
+
102
+ def self.reject_offset_zero(query)
103
+ query.reject do |query_part|
104
+ query_part[0] == 'offset' && query_part[1] == 0
105
+ end
106
+ end
107
+
108
+ end
109
+ end
110
+ end