volt-sql 0.0.1

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: 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