mystic 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: e7f8ba8f872bed5ad3946fb3eca8373a5262c820
4
+ data.tar.gz: 874d9a10179f9c9e15a6fffb7451ff03ad70788a
5
+ SHA512:
6
+ metadata.gz: c10435a23302bdf3e5aec546b164b4dc73a021b9d12221e2e1387e26086a2db21f603896aa3ef0b49daaa464438d1cec143861ecac0cb7421e057c9489cfd98a
7
+ data.tar.gz: 5333e387710b4b0d19b81664359becf2a79f101e9d655820d256388a4488543b3587219514e4a2d2c212d931cb7806ee522546e75f974b2f838cdd8eb19af919
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) <year> <copyright holders>
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
13
+ all 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
21
+ THE SOFTWARE.
data/bin/mystic ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "mystic"
4
+ require "irb"
5
+
6
+ # Examples:
7
+ # mystic create migration InitialMigration
8
+ # mystic migrate development
9
+ # mystic rollback production
10
+
11
+ ErrorOutput = Class.new StandardError
12
+
13
+ begin
14
+ case ARGV[0].to_sym
15
+ when :create
16
+ case ARGV[1].to_sym
17
+ when :migration
18
+ raise ErrorOutput, "No migration name provided" if ARGV[2].nil?
19
+ Mystic.create_migration ARGV[2].dup
20
+ end
21
+ else
22
+ begin
23
+ Mystic.connect ARGV[1]
24
+ case ARGV[0].to_sym
25
+ when :migrate
26
+ Mystic.migrate
27
+ when :rollback
28
+ Mystic.rollback
29
+ when :console
30
+ puts "Starting Mystic console: #{Mystic.env}"
31
+ IRB.setup nil
32
+ IRB.conf[:MAIN_CONTEXT] = IRB::Irb.new.context
33
+ require "irb/ext/multi-irb"
34
+ IRB.irb nil, IRB::WorkSpace.new
35
+ end
36
+ Mystic.disconnect
37
+ rescue Mystic::EnvironmentError => e
38
+ puts e.message
39
+ end
40
+ end
41
+ rescue ErrorOutput => e
42
+ puts e.message
43
+ end
data/lib/mystic.rb ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+ require "mystic/extensions"
6
+ require "mystic/constants"
7
+ require "mystic/sql"
8
+ require "mystic/adapter"
9
+ require "mystic/migration"
10
+ require "mystic/model"
11
+
12
+ module Mystic
13
+ @@adapter = nil
14
+
15
+ class << self
16
+ def adapter
17
+ @@adapter
18
+ end
19
+
20
+ # Mystic.connect
21
+ # Connects to a database. It's recommended you use it like ActiveRecord::Base.establish_connection
22
+ # Arguments:
23
+ # env - The env from database.yml you wish to use
24
+ def connect(env="")
25
+ load_env
26
+ @@env = (env || ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development").to_s
27
+ path = root.join("config","database.yml").to_s
28
+ db_yml = YAML.load_file path
29
+
30
+ raise EnvironmentError, "Environment '#{@@env}' doesn't exist." unless db_yml.member? @@env
31
+
32
+ conf = db_yml[@@env].symbolize
33
+ conf[:dbname] = conf[:database]
34
+
35
+ @@adapter = Adapter.create(
36
+ conf[:adapter],
37
+ :pool_size => conf[:pool_size].to_i,
38
+ :pool_timeout => conf[:timeout].to_i,
39
+ :pool_expires => conf[:expires].to_i
40
+ )
41
+
42
+ @@adapter.connect conf
43
+ true
44
+ end
45
+
46
+ alias_method :env=, :connect
47
+
48
+ def env
49
+ @@env
50
+ end
51
+
52
+ # Mystic.disconnect
53
+ # Disconnects from the connected database. Use it like ActiveRecord::Base.connection.disconnect!
54
+ def disconnect
55
+ @@adapter.disconnect
56
+ @@adapter = nil
57
+ true
58
+ end
59
+
60
+ # Mystic.execute
61
+ # Execute some sql. It will be densified (the densify gem) and sent to the DB
62
+ # Arguments:
63
+ # sql - The SQL to execute
64
+ # Returns: Native Ruby objects representing the response from the DB (Usually an Array of Hashes)
65
+ def execute(sql="")
66
+ raise AdapterError, "Adapter is nil, so Mystic is not connected." if @@adapter.nil?
67
+ @@adapter.execute sql.sql_terminate.densify
68
+ end
69
+
70
+ # Mystic.sanitize
71
+ # Escape a string so that it can be used safely as input. Mystic does not support statement preparation, so this is a must.
72
+ # Arguments:
73
+ # str - The string to sanitize
74
+ # Returns: the sanitized string
75
+ def sanitize(str="")
76
+ raise AdapterError, "Adapter is nil, so Mystic is not connected." if @@adapter.nil?
77
+ @@adapter.sanitize str
78
+ end
79
+
80
+ # Mystic.root
81
+ # Get the app root
82
+ # Aguments:
83
+ # To be ignored
84
+ # Returns:
85
+ # A pathname to the application's root
86
+ def root(path=Pathname.new(Dir.pwd))
87
+ raise RootError, "Failed to find the application's root." if path == path.parent
88
+ mystic_path = path.join "config", "database.yml"
89
+ return path if mystic_path.file? # exist? is implicit with file?
90
+ root path.parent
91
+ end
92
+
93
+ # TODO: Make this a migration
94
+ # TODO: Silence this
95
+ # Mystic.create_table
96
+ # Create migration tracking table
97
+ def create_mig_table
98
+ execute "CREATE TABLE IF NOT EXISTS mystic_migrations (mig_number integer, filename text)"
99
+ end
100
+
101
+ #
102
+ # Command line
103
+ #
104
+
105
+ # Runs every yet-to-be-ran migration
106
+ def migrate
107
+ create_mig_table
108
+ migrated_filenames = execute("SELECT filename FROM mystic_migrations").map{ |r| r["filename"] }
109
+ mp = root.join("mystic","migrations").to_s
110
+
111
+ Dir.entries(mp)
112
+ .reject{ |e| MIG_REGEX.match(e).nil? }
113
+ .reject{ |e| migrated_filenames.include? e }
114
+ .sort{ |a,b| MIG_REGEX.match(a)[:num].to_i <=> MIG_REGEX.match(b)[:num].to_i }
115
+ .each{ |fname|
116
+ load File.join mp,fname
117
+
118
+ mig_num,mig_name = MIG_REGEX.match(fname).captures
119
+ Object.const_get(mig_name).new.migrate
120
+ execute "INSERT INTO mystic_migrations (mig_number, filename) VALUES (#{mig_num},'#{fname}')"
121
+ }
122
+ end
123
+
124
+ # Rolls back a single migration
125
+ def rollback
126
+ create_mig_table
127
+ fname = execute("SELECT filename FROM mystic_migrations ORDER BY mig_number DESC LIMIT 1")[0]["filename"] rescue nil
128
+ return if fname.nil?
129
+
130
+ load root.join("mystic","migrations",fname).to_s
131
+
132
+ mig_num,mig_name = MIG_REGEX.match(fname).captures
133
+
134
+ Object.const_get(mig_name).new.rollback
135
+
136
+ execute "DELETE FROM mystic_migrations WHERE filename='#{fname}' and mig_number=#{mig_num}"
137
+ end
138
+
139
+ # Creates a blank migration in mystic/migrations
140
+ def create_migration(name="")
141
+ name.strip!
142
+ raise CLIError, "Migration name must not be empty." if name.empty?
143
+
144
+ name[0] = name[0].capitalize
145
+
146
+ migs = root.join "mystic","migrations"
147
+
148
+ num = migs.entries.map{ |e| MIG_REGEX.match(e.to_s)[:num].to_i rescue 0 }.max.to_i+1
149
+
150
+ File.open(migs.join("#{num}_#{name}.rb").to_s, 'w') { |f| f.write(template name) }
151
+ end
152
+
153
+ private
154
+
155
+ # Loads the .env file
156
+ def load_env
157
+ root.join(".env").read
158
+ .split("\n")
159
+ .map { |l| l.strip.split "=", 2 }
160
+ .each { |k,v| ENV[k] = v }
161
+ end
162
+
163
+ # Retuns a blank migration's code in a String
164
+ def template(name=nil)
165
+ raise ArgumentError, "Migrations must have a name." if name.nil?
166
+ <<-mig_template
167
+ #!/usr/bin/env ruby
168
+
169
+ require "mystic"
170
+
171
+ class #{name} < Mystic::Migration
172
+ def up
173
+
174
+ end
175
+
176
+ def down
177
+
178
+ end
179
+ end
180
+ mig_template
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "densify"
4
+ require "access_stack"
5
+
6
+ module Mystic
7
+ class Adapter
8
+ attr_accessor :pool_size,
9
+ :pool_timeout,
10
+ :pool_expires
11
+
12
+ @@blocks = {}
13
+
14
+ def self.create(name="",opts={})
15
+ name = name.to_s.downcase.strip
16
+ name = "postgres" if name =~ /^postg.*$/i # Includes PostGIS
17
+ name = "mysql" if name =~ /^mysql.*$/i
18
+
19
+ require "mystic/adapters/" + name
20
+
21
+ Object.const_get("Mystic::#{name.capitalize}Adapter").new opts
22
+ end
23
+
24
+ def initialize(opts={})
25
+ opts.symbolize.each do |k,v|
26
+ k = ('@' + k.to_s).to_sym
27
+ instance_variable_set k,v if instance_variables.include? k
28
+ end
29
+ end
30
+
31
+ # Gets the adapter name (examples: postgres, mysql)
32
+ def self.adapter
33
+ a = name.split("::").last.sub("Adapter","").downcase
34
+ a = "abstract" if a.empty?
35
+ a
36
+ end
37
+
38
+ def adapter
39
+ self.class.adapter
40
+ end
41
+
42
+ # Map a block to an adapter's block hash
43
+ # This avoids cases where a subclass' class var
44
+ # changes that class var on its superclass
45
+ def self.map_block(key, block)
46
+ @@blocks[adapter] ||= {}
47
+ @@blocks[adapter][key] = block
48
+ end
49
+
50
+ # Fetch a block for the current adapter
51
+ def block_for(key)
52
+ b = @@blocks[adapter][key] rescue nil
53
+ b ||= @@blocks["abstract"][key] rescue nil
54
+ b ||= lambda { "" }
55
+ b
56
+ end
57
+
58
+ #
59
+ # Adapter DSL
60
+ # Implemented using method_missing
61
+ #
62
+
63
+ # example
64
+ # execute do |inst,sql|
65
+ # inst.exec sql
66
+ # end
67
+
68
+ # execute(inst,sql)
69
+ # Executes SQL and returns Ruby types
70
+
71
+ # TODO: different kinds of escaping: ident & quote
72
+
73
+ # sanitize(inst,sql)
74
+ # Escapes a literal
75
+
76
+ # connect(opts)
77
+ # Creates an instance of the DB connection
78
+
79
+ # disconnect(inst)
80
+ # Disconnects and destroys inst (a database connection)
81
+
82
+ # validate(inst)
83
+ # Checks if inst is a valid connection
84
+
85
+ # Map missing methods to blocks
86
+ # DB ops or SQL generation
87
+ def self.method_missing(meth, *args, &block)
88
+ map_block meth, block
89
+ end
90
+
91
+ #
92
+ # Adapter methods
93
+ # These are called internally
94
+ # They are called by class methods
95
+ # in ../mystic.rb
96
+ #
97
+ def connect(opts)
98
+ disconnect unless @pool.nil?
99
+ @pool = AccessStack.new(
100
+ :size => @pool_size,
101
+ :timeout => @pool_timeout,
102
+ :expires => @pool_expires,
103
+ :create => lambda { block_for(:connect).call opts }
104
+ )
105
+ end
106
+
107
+ def disconnect
108
+ @pool.destroy ||= block_for :disconnect
109
+ @pool.empty!
110
+ end
111
+
112
+ def reap
113
+ @pool.validate ||= block_for :validate
114
+ @pool.reap
115
+ end
116
+
117
+ def execute(sql)
118
+ raise AdapterError, "Adapter's connection pool doesn't exist and so Mystic has not connected to the database." if @pool.nil?
119
+ @pool.with { |inst| block_for(:execute).call inst, sql }
120
+ end
121
+
122
+ def sanitize(str)
123
+ @pool.with { |inst| block_for(:sanitize).call inst, str }
124
+ end
125
+
126
+ def json_supported?
127
+ block_for(:json_supported).call
128
+ end
129
+
130
+ def serialize_sql(obj)
131
+ return case obj
132
+ when SQL::Table
133
+ block_for(:table).call obj
134
+ when SQL::Index
135
+ block_for(:index).call obj
136
+ when SQL::Column
137
+ block_for(:column).call obj
138
+ when SQL::Operation
139
+ res = block_for(obj.kind).call obj
140
+ obj.callback.call unless obj.callback.nil?
141
+ res
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "mystic"
4
+ require "mystic/adapter"
5
+ require "mystic/sql"
6
+
7
+ #
8
+ # This adapter is designed to hold
9
+ # basic, spec-adherent SQL generation
10
+ # so adapters can be more DRY
11
+ #
12
+
13
+ module Mystic
14
+ class Adapter
15
+
16
+ json_supported { false }
17
+
18
+ table do |obj|
19
+ sql = []
20
+ sql.push "CREATE TABLE #{obj.name} (#{obj.columns.map(&:to_sql)*","})" if obj.create?
21
+ sql.push "ALTER TABLE #{obj.name} #{obj.columns.map{|c| "ADD COLUMN #{c.to_sql}" }*', '}" unless obj.create?
22
+ sql.push *(obj.indeces.map(&:to_sql)) unless obj.indeces.empty?
23
+ sql.push *(obj.operations.map(&:to_sql)) unless obj.operations.empty?
24
+ sql*"; "
25
+ end
26
+
27
+ column do |obj|
28
+ sql = []
29
+ sql << obj.name
30
+ sql << obj.kind.downcase
31
+ sql << "(#{obj.size})" if obj.size && !obj.size.empty? && !obj.geospatial?
32
+ sql << "(#{obj.geom_kind}, #{obj.geom_srid})" if obj.geospatial?
33
+ sql << (obj.constraints[:null] ? "NULL" : "NOT NULL") if obj.constraints.member?(:null)
34
+ sql << "UNIQUE" if obj.constraints[:unique]
35
+ sql << "PRIMARY KEY" if obj.constraints[:primary_key]
36
+ sql << "REFERENCES " + obj.constraints[:references] if obj.constraints.member?(:references)
37
+ sql << "DEFAULT " + obj.constraints[:default] if obj.constraints.member?(:default)
38
+ sql << "CHECK(#{obj.constraints[:check]})" if obj.constraints.member?(:check)
39
+ sql*" "
40
+ end
41
+
42
+ index do |index|
43
+ sql = []
44
+ sql << "CREATE"
45
+ sql << "UNIQUE" if index.unique
46
+ sql << "INDEX"
47
+ sql << index.name if index.name
48
+ sql << "ON"
49
+ sql << index.table_name
50
+ sql << "(#{index.columns.map(&:to_s).join(',')})"
51
+ sql*" "
52
+ end
53
+
54
+ #
55
+ ## Operations
56
+ #
57
+
58
+ drop_columns do |obj|
59
+ "ALTER TABLE #{obj.table_name} #{obj.column_names.map{|c| "DROP COLUMN #{c.to_s}" }*', '}"
60
+ end
61
+
62
+ rename_column do |obj|
63
+ "ALTER TABLE #{obj.table_name} RENAME COLUMN #{obj.old_name} TO #{obj.new_name}"
64
+ end
65
+
66
+ create_view do |obj|
67
+ "CREATE VIEW #{obj.name} AS #{obj.sql}"
68
+ end
69
+
70
+ drop_view do |obj|
71
+ "DROP VIEW #{obj.name}"
72
+ end
73
+
74
+ drop_table do |obj|
75
+ "DROP TABLE #{obj.table_name}"
76
+ end
77
+
78
+ rename_table do |obj|
79
+ "ALTER TABLE #{obj.old_name} RENAME TO #{obj.new_name}"
80
+ end
81
+
82
+ #
83
+ ## Transaction Operations
84
+ #
85
+
86
+ start_transaction { "BEGIN" }
87
+ commit_transaction { "COMMIT" }
88
+ rollback_transaction { "ROLLBACK" }
89
+ end
90
+ end
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "mysql2"
4
+
5
+ # TODO:
6
+ # 1. Implement geometry
7
+
8
+ module Mystic
9
+ class MysqlAdapter < Mystic::Adapter
10
+ ALGORITHMS = [:default, :inplace, :copy]
11
+ LOCKS = [:default, :none, :shared, :exclusive]
12
+
13
+ connect { |opts| Mysql2::client.new opts }
14
+ disconnect { |mysql| mysql.close }
15
+ validate { |mysql| mysql.ping }
16
+ execute { |mysql, sql| mysql.query(sql).to_a }
17
+ sanitize { |mysql, str| mysql.escape str }
18
+
19
+ drop_index do |index|
20
+ "DROP INDEX #{index.name} ON #{index.table_name}"
21
+ end
22
+
23
+ index do |index|
24
+ sql = []
25
+ sql << "CREATE"
26
+ sql << "UNIQUE" if index.unique
27
+ sql << "INDEX"
28
+ sql << index.name if index.name
29
+ sql << "ON"
30
+ sql << index.table_name
31
+ sql << "USING #{obj.type.to_s.capitalize}" if index.type
32
+ sql << "(#{index.columns.map(&:to_s).join ',' })"
33
+ sql << "COMMENT #{index.comment.truncate 1024}" if index.comment
34
+ sql << "ALGORITHM #{index.algorithm.to_s.upcase}" if !index.lock && ALGORITHMS.include? index.algorithm
35
+ sql << "LOCK #{index.lock.to_s.upcase}" if !index.algorithm && LOCKS.include? index.lock
36
+ sql*" "
37
+ end
38
+
39
+ # Leverage MySQL savepoints to make up for transactions' flaws
40
+ start_transaction { <<-sql
41
+ BEGIN;
42
+ SAVEPOINT mystic_migration8889;
43
+ DECLARE EXIT HANDLER FOR SQLWARNING ROLLBACK TO mystic_migration8889;
44
+ DECLARE EXIT HANDLER FOR NOT FOUND ROLLBACK TO mystic_migration8889;
45
+ DECLARE EXIT HANDLER FOR SQLEXCEPTION ROLLBACK TO mystic_migration8889;
46
+ sql
47
+ }
48
+ commit_transaction { "RELEASE SAVEPOINT mystic_migration8889;COMMIT" }
49
+ rollback_transaction { "ROLLBACK TO mystic_migration8889; RELEASE SAVEPOINT mystic_migration8889; ROLLBACK" }
50
+ end
51
+ end
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "mystic/adapters/abstract"
4
+ require "pg"
5
+
6
+ # Mystic adapter for Postgres, includes PostGIS
7
+
8
+ module Mystic
9
+ class PostgresAdapter < Mystic::Adapter
10
+ INDEX_TYPES = [
11
+ :btree,
12
+ :hash,
13
+ :gist,
14
+ :spgist,
15
+ :gin
16
+ ].freeze
17
+
18
+ CONNECT_FIELDS = [
19
+ :host,
20
+ :hostaddr,
21
+ :port,
22
+ :dbname,
23
+ :user,
24
+ :password,
25
+ :connect_timeout,
26
+ :options,
27
+ :tty,
28
+ :sslmode,
29
+ :krbsrvname,
30
+ :gsslib
31
+ ].freeze
32
+
33
+ connect { |opts| PG.connect opts.subhash(*CONNECT_FIELDS) }
34
+ disconnect { |pg| pg.close }
35
+ validate { |pg| pg.status == CONNECTION_OK }
36
+ sanitize { |pg, str| pg.escape_string str }
37
+ json_supported { true }
38
+
39
+ execute do |pg, sql|
40
+ res = pg.exec sql
41
+ v = res[0][Mystic::JSON_COL] if res.ntuples == 1 && res.nfields == 1
42
+ v ||= res.ntuples.times.map { |i| res[i] } unless res.nil?
43
+ v ||= []
44
+ v
45
+ end
46
+
47
+ drop_index do |index|
48
+ "DROP INDEX #{index.index_name}"
49
+ end
50
+
51
+ create_ext do |ext|
52
+ "CREATE EXTENSION \"#{ext.name}\""
53
+ end
54
+
55
+ drop_ext do |ext|
56
+ "DROP EXTENSION \"#{ext.name}\""
57
+ end
58
+
59
+ index do |index|
60
+ storage_params = index.opts.subhash :fillfactor, :buffering, :fastupdate
61
+
62
+ sql = []
63
+ sql << "CREATE"
64
+ sql << "UNIQUE" if index.unique
65
+ sql << "INDEX"
66
+ sql << "CONCURENTLY" if index.concurrently
67
+ sql << index.name unless index.name.nil?
68
+ sql << "ON #{index.table_name}"
69
+ sql << "USING #{index.type}" if INDEX_TYPES.include? index.type
70
+ sql << "(#{index.columns.map(&:to_s).join ',' })"
71
+ sql << "WITH (#{storage_params.sqlize})" unless storage_params.empty?
72
+ sql << "TABLESPACE #{index.tablespace}" unless index.tablespace.nil?
73
+ sql << "WHERE #{index.where}" unless index.where.nil?
74
+ sql*' '
75
+ end
76
+
77
+ table do |table|
78
+ sql = []
79
+
80
+ if table.create?
81
+ tbl = []
82
+ tbl << "CREATE TABLE #{table.name} (#{table.columns.map(&:to_sql)*","})"
83
+ tbl << "INHERITS #{table.inherits}" if table.inherits
84
+ tbl << "TABLESPACE #{table.tablespace}" if table.tablespace
85
+ sql << tbl*' '
86
+ else
87
+ sql << "ALTER TABLE #{table.name} #{table.columns.map{ |c| "ADD COLUMN #{c.to_sql}" }*', ' }"
88
+ end
89
+
90
+ sql.push(*table.indeces.map(&:to_sql)) unless table.indeces.empty?
91
+ sql.push(*table.operations.map(&:to_sql)) unless table.operations.empty?
92
+ sql*'; '
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module Mystic
4
+ MysticError = Class.new StandardError
5
+ RootError = Class.new StandardError
6
+ EnvironmentError = Class.new StandardError
7
+ AdapterError = Class.new StandardError
8
+ CLIError = Class.new StandardError
9
+ MIG_REGEX = /(?<num>\d+)_(?<name>[a-z]+)\.rb$/i # matches migration files (ex '1_MigrationClassName.rb')
10
+ JSON_COL = "mystic_return_json89788"
11
+ end
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module Kernel
4
+ def self.silent
5
+ v = $VERBOSE
6
+ $VERBOSE = false
7
+ yield
8
+ $VERBOSE = v
9
+ nil
10
+ end
11
+ end
12
+
13
+ class String
14
+ def desnake
15
+ downcase.split("_").map(&:capitalize)*' '
16
+ end
17
+
18
+ def sanitize
19
+ Mystic.sanitize(self).untaint
20
+ end
21
+
22
+ def truncate(len)
23
+ self[0..len-1]
24
+ end
25
+
26
+ def sql_terminate
27
+ return self + ";" unless dup.strip.end_with? ";"
28
+ self
29
+ end
30
+ end
31
+
32
+ class Array
33
+ def merge_keys(*keys)
34
+ raise ArgumentError, "No keys to merge." if keys.nil? || keys.empty?
35
+ raise ArgumentError, "Argument array must have the same number of elements as self." if keys.count != self.count
36
+ Hash[each_with_index.map{ |v,i| [keys[i],v] }]
37
+ end
38
+
39
+ def symbolize
40
+ map(&:to_sym)
41
+ end
42
+
43
+ def symbolize!
44
+ map!(&:to_sym)
45
+ end
46
+
47
+ def sqlize
48
+ map { |o|
49
+ case o
50
+ when String
51
+ "'#{o.sanitize}'"
52
+ when Numeric
53
+ o.to_s
54
+ end
55
+ }.compact
56
+ end
57
+ end
58
+
59
+ class Hash
60
+ def subhash(*keys)
61
+ Hash[values_at(*keys).merge_keys(*keys).reject{ |k,v| v.nil? }]
62
+ end
63
+
64
+ def parify(delim=" ")
65
+ map { |pair| pair * delim }
66
+ end
67
+
68
+ def compact
69
+ reject { |k,v| v.nil? }
70
+ end
71
+
72
+ def compact!
73
+ reject! { |k,v| v.nil? }
74
+ end
75
+
76
+ def symbolize
77
+ Hash[map { |k,v| [k.to_sym, v]}]
78
+ end
79
+
80
+ def symbolize!
81
+ keys.each { |key| self[key.to_sym] = delete key }
82
+ end
83
+
84
+ def sqlize
85
+ reject { |k,v| v.nil? || v.empty? }.map{ |k,v| "#{k}=#{Integer === v ? v : "'#{v.to_s.sanitize}'" }" }
86
+ end
87
+ end
88
+
89
+ class Pathname
90
+ Kernel.silent do
91
+ def relative?
92
+ @path[0] != File::SEPARATOR
93
+ end
94
+
95
+ def join(*args)
96
+ Pathname.new(File.join @path, *args.map(&:to_s))
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module Mystic
4
+ class Migration
5
+ Error = Class.new(StandardError)
6
+ IrreversibleError = Class.new(StandardError)
7
+
8
+ def initialize
9
+ @irreversible = false
10
+ @sql = ""
11
+ end
12
+
13
+ def migrate
14
+ exec_migration :up
15
+ end
16
+
17
+ def rollback
18
+ exec_migration :down
19
+ end
20
+
21
+ # TODO: This is ugly... It needs cleaning up.
22
+ def exec_migration(direction)
23
+ @sql = ""
24
+
25
+ direction = direction.to_sym
26
+
27
+ raise ArgumentError, "Direction must be either :up or :down." unless [:up, :down].include? direction
28
+ raise IrreversibleError, "Impossible to roll back an irreversible migration." if direction == :down && irreversible?
29
+
30
+ execute Mystic::SQL::Operation.start_transaction
31
+ method(direction).call
32
+ execute Mystic::SQL::Operation.commit_transaction
33
+
34
+ Mystic.adapter.execute @sql # bypass densification
35
+ end
36
+
37
+
38
+ #
39
+ # DSL
40
+ #
41
+
42
+ # All migration SQL goes through here
43
+ def execute(obj)
44
+ @sql << obj.to_s.sql_terminate # to_sql isn't defined for strings, to_sql is aliased to to_s
45
+ end
46
+
47
+ def irreversible!
48
+ @irreversible = true
49
+ end
50
+
51
+ def irreversible?
52
+ @irreversible
53
+ end
54
+
55
+ def create_table(name)
56
+ raise ArgumentError, "No block provided, a block is required to create a table." unless block_given?
57
+ table = Mystic::SQL::Table.create :name => name
58
+ yield table
59
+ execute table
60
+ end
61
+
62
+ def alter_table(name)
63
+ raise ArgumentError, "No block provided, a block is required to alter a table." unless block_given?
64
+ table = Mystic::SQL::Table.alter :name => name
65
+ yield table
66
+ execute table
67
+ end
68
+
69
+ def drop_table(name)
70
+ irreversible!
71
+ execute Mystic::SQL::Operation.drop_table(
72
+ :table_name => name.to_s
73
+ )
74
+ end
75
+
76
+ def drop_index(*args)
77
+ execute Mystic::SQL::Operation.drop_index(
78
+ :index_name => args[0],
79
+ :table_name => args[1]
80
+ )
81
+ end
82
+
83
+ def create_ext(extname)
84
+ execute Mystic::SQL::Operation.create_ext(
85
+ :name => extname.to_s
86
+ )
87
+ end
88
+
89
+ def drop_ext(extname)
90
+ execute Mystic::SQL::Operation.drop_ext(
91
+ :name => extname.to_s
92
+ )
93
+ end
94
+
95
+ def create_view(name, sql)
96
+ execute Mystic::SQL::Operation.create_view(
97
+ :name => name.to_s,
98
+ :sql => sql.to_s
99
+ )
100
+ end
101
+
102
+ def drop_view(name)
103
+ execute Mystic::SQL::Operation.drop_view(
104
+ :name => name.to_s
105
+ )
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module Mystic
4
+ class Model
5
+ def self.table_name
6
+ to_s.downcase
7
+ end
8
+
9
+ def self.visible_cols
10
+ ["*"]
11
+ end
12
+
13
+ def self.wrapper_sql(opts={})
14
+ sym_opts = opts.symbolize
15
+
16
+ sql = sym_opts[:sql] || "SELECT 1"
17
+ return_rows = sym_opts[:return_rows] || false
18
+ return_json = sym_opts[:return_json] || false
19
+ return_rows = true if return_json
20
+
21
+ op = sql.split(/\s+/,2).first
22
+
23
+ sql << " RETURNING #{visible_cols*','}" if return_rows && op != "SELECT"
24
+
25
+ s = []
26
+
27
+ if return_json
28
+ s << "WITH res AS (#{sql}) SELECT"
29
+ s << "row_to_json(res)" if op == "INSERT"
30
+ s << "array_to_json(array_agg(res))" unless op == "INSERT"
31
+ s << "AS #{Mystic::JSON_COL}"
32
+ s << "FROM res"
33
+ else
34
+ s << sql
35
+ end
36
+
37
+ s*' '
38
+ end
39
+
40
+ def self.function_sql(returns_rows, funcname, *params)
41
+ "SELECT #{returns_rows ? "* FROM" : ""} #{funcname}(#{params.sqlize*','})"
42
+ end
43
+
44
+ def self.select_sql(params={}, opts={})
45
+ sym_opts = opts.symbolize
46
+ count = sym_opts[:count] || 0
47
+ where = params.sqlize
48
+
49
+ sql = []
50
+ sql << "SELECT #{visible_cols*','} FROM #{table_name}"
51
+ sql << "WHERE #{where*' AND '}" if where.count > 0
52
+ sql << "LIMIT #{count.to_i}" if count > 0
53
+
54
+ wrapper_sql(
55
+ :sql => sql.join(' '),
56
+ :return_rows => true,
57
+ :return_json => sym_opts[:return_json] && Mystic.adapter.json_supported?
58
+ )
59
+ end
60
+
61
+ def self.update_sql(where={}, set={}, opts={})
62
+ return "" if where.empty?
63
+ return "" if set.empty?
64
+
65
+ sym_opts = opts.symbolize
66
+
67
+ wrapper_sql(
68
+ :sql => "UPDATE #{table_name} SET #{set.sqlize*','} WHERE #{where.sqlize*' AND '}",
69
+ :return_rows => sym_opts[:return_rows],
70
+ :return_json => sym_opts[:return_json] && Mystic.adapter.json_supported?
71
+ )
72
+ end
73
+
74
+ def self.insert_sql(params={}, opts={})
75
+ return "" if params.empty?
76
+
77
+ sym_opts = opts.symbolize
78
+
79
+ wrapper_sql(
80
+ :sql => "INSERT INTO #{table_name} (#{params.keys*','}) VALUES (#{params.values.sqlize*','})",
81
+ :return_rows => sym_opts[:return_rows],
82
+ :return_json => sym_opts[:return_json] && Mystic.adapter.json_supported?
83
+ )
84
+ end
85
+
86
+ def self.delete_sql(params={}, opts={})
87
+ return "" if params.empty?
88
+
89
+ sym_opts = opts.symbolize
90
+
91
+ wrapper_sql(
92
+ :sql => "DELETE FROM #{table_name} WHERE #{params.sqlize*' AND '}",
93
+ :return_rows => sym_opts[:return_rows],
94
+ :return_json => sym_opts[:return_json] && Mystic.adapter.json_supported?
95
+ )
96
+ end
97
+
98
+ def self.select(params={}, opts={})
99
+ Mystic.execute select_sql(params, opts)
100
+ end
101
+
102
+ def self.fetch(params={}, opts={})
103
+ res = select params, opts.merge({:count => 1})
104
+ return res if res.is_a? String
105
+ res.first rescue nil
106
+ end
107
+
108
+ def self.create(params={}, opts={})
109
+ res = Mystic.execute insert_sql(params, opts)
110
+ return res if res.is_a? String
111
+ res.first rescue nil
112
+ end
113
+
114
+ def self.update(where={}, set={}, opts={})
115
+ Mystic.execute update_sql(where, set, opts.merge({ :return_rows => true }))
116
+ end
117
+
118
+ def self.delete(params={}, opts={})
119
+ Mystic.execute delete_sql(params, opts)
120
+ end
121
+
122
+ def self.exec_func(funcname, *params)
123
+ Mystic.execute function_sql(false, funcname, *params)
124
+ end
125
+
126
+ def self.exec_func_rows(funcname, *params)
127
+ Mystic.execute function_sql(true, funcname, *params)
128
+ end
129
+ end
130
+ end
data/lib/mystic/sql.rb ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module Mystic
4
+ module SQL
5
+ Error = Class.new(StandardError)
6
+
7
+ class SQLObject
8
+ def to_sql
9
+ Mystic.adapter.serialize_sql self
10
+ end
11
+
12
+ alias_method :to_s, :to_sql
13
+ end
14
+
15
+ class Index < SQLObject
16
+ attr_accessor :name, # Symbol or string
17
+ :table_name, # Symbol or string
18
+ :type, # Symbol
19
+ :unique, # TrueClass/FalseClass
20
+ :columns, # Array of Strings
21
+ :opts # Hash, see below
22
+
23
+ # opts
24
+ # It's a Hash that represents options
25
+ #
26
+ # MYSQL ONLY
27
+ # Key => Value (type)
28
+ # :comment => A string that's up to 1024 chars (String)
29
+ # :algorithm => The algorithm to use (Symbol)
30
+ # :lock => The lock to use (Symbol)
31
+ #
32
+ # POSTGRES ONLY
33
+ # Key => Value (type)
34
+ # :fillfactor => A value in the range 10..100 (Integer)
35
+ # :fastupdate => true/false (TrueClass/FalseClass)
36
+ # :concurrently => true/false (TrueClass/FalseClass)
37
+ # :tablespace => The name of the desired tablespace (String)
38
+ # :buffering => :on/:off/:auto (Symbol)
39
+ # :concurrently => true/false (TrueClass/FalseClass)
40
+ # :where => The conditions for including entries in your index, same as SELECT * FROM table WHERE ____ (String)
41
+
42
+ def initialize(opts={})
43
+ opts.symbolize!
44
+ raise ArgumentError, "Indeces need a table_name or else what's the point?." unless opts.member? :table_name
45
+ raise ArgumentError, "Indeces need columns or else what's the point?" unless opts.member? :columns
46
+ @name = opts.delete(:name).to_sym if opts.member? :name
47
+ @table_name = opts.delete(:table_name).to_sym
48
+ @type = (opts.delete :type || :btree).to_s.downcase.to_sym
49
+ @unique = opts.delete :unique || false
50
+ @columns = opts.delete(:columns).symbolize rescue []
51
+ @opts = opts
52
+ end
53
+
54
+ # can accept shit other than columns like
55
+ # box(location,location)
56
+ def <<(col)
57
+ case col
58
+ when Column
59
+ @columns << col.name.to_s
60
+ when String
61
+ @columns << col
62
+ else
63
+ raise ArgumentError, "Column must be a String or a Mystic::SQL::Column"
64
+ end
65
+ end
66
+
67
+ alias_method :push, :<<
68
+
69
+ def method_missing(meth, *args, &block)
70
+ return @opts[meth] if @opts.member? meth
71
+ nil
72
+ end
73
+ end
74
+
75
+ class Column < SQLObject
76
+ attr_accessor :name, :kind, :size, :constraints, :geom_kind, :geom_srid
77
+
78
+ def initialize(opts={})
79
+ @name = opts.delete(:name).to_s
80
+ @kind = opts.delete(:kind).to_sym
81
+ @size = opts.delete(:size).to_s if opts.member? :size
82
+ @geom_kind = opts.delete(:geom_kind)
83
+ @geom_srid = opts.delete(:geom_srid).to_i
84
+ @constraints = opts
85
+ end
86
+
87
+ def geospatial?
88
+ @geom_kind && @geom_srid
89
+ end
90
+ end
91
+
92
+ class Table < SQLObject
93
+ attr_reader :name
94
+ attr_accessor :columns,
95
+ :indeces,
96
+ :operations,
97
+ :opts
98
+
99
+ def self.create(opts={})
100
+ new true, opts
101
+ end
102
+
103
+ def self.alter(opts={})
104
+ new false, opts
105
+ end
106
+
107
+ def initialize(is_create=true, opts={})
108
+ @is_create = is_create
109
+ @opts = opts.symbolize
110
+ @columns = []
111
+ @indeces = []
112
+ @operations = []
113
+
114
+ @name = @opts.delete(:name).to_s
115
+ raise ArgumentError, "Argument 'name' is invalid." if @name.empty?
116
+ end
117
+
118
+ def create?
119
+ @is_create
120
+ end
121
+
122
+ def <<(obj)
123
+ case obj
124
+ when Column
125
+ @columns << obj
126
+ when Index
127
+ @indeces << obj
128
+ when Operation
129
+ @operations << obj
130
+ else
131
+ raise ArgumentError, "Argument is not a Mystic::SQL::Column, Mystic::SQL::Operation, or Mystic::SQL::Index."
132
+ end
133
+ end
134
+
135
+ def to_sql
136
+ raise ArgumentError, "Table cannot have zero columns." if @columns.empty?
137
+ super
138
+ end
139
+
140
+ alias_method :push, :<<
141
+
142
+ #
143
+ ## Operation DSL
144
+ #
145
+
146
+ def drop_index(idx_name)
147
+ raise Mystic::SQL::Error, "Cannot drop an index on a table that doesn't exist." if create?
148
+ self << Mystic::SQL::Operation.drop_index(
149
+ :index_name => idx_name.to_s,
150
+ :table_name => self.name.to_s
151
+ )
152
+ end
153
+
154
+ def rename_column(oldname, newname)
155
+ raise Mystic::SQL::Error, "Cannot rename a column on a table that doesn't exist." if create?
156
+ self << Mystic::SQL::Operation.rename_column(
157
+ :table_name => self.name.to_s,
158
+ :old_name => oldname.to_s,
159
+ :new_name => newname.to_s
160
+ )
161
+ end
162
+
163
+ def rename(newname)
164
+ raise Mystic::SQL::Error, "Cannot rename a table that doesn't exist." if create?
165
+ self << Mystic::SQL::Operation.rename_table(
166
+ :old_name => self.name.dup.to_s,
167
+ :new_name => newname.to_s,
168
+ :callback => lambda { self.name = newname }
169
+ )
170
+ end
171
+
172
+ def drop_columns(*col_names)
173
+ raise Mystic::SQL::Error, "Cannot drop a column(s) on a table that doesn't exist." if create?
174
+ self << Mystic::SQL::Operation.drop_columns(
175
+ :table_name => self.name.to_s,
176
+ :column_names => col_names.map(&:to_s)
177
+ )
178
+ end
179
+
180
+ #
181
+ ## Column DSL
182
+ #
183
+
184
+ def column(col_name, kind, opts={})
185
+ self << Mystic::SQL::Column.new({
186
+ :name => col_name,
187
+ :kind => kind.to_sym
188
+ }.merge(opts || {}))
189
+ end
190
+
191
+ def geometry(col_name, kind, srid, opts={})
192
+ self << Mystic::SQL::Column.new({
193
+ :name => col_name,
194
+ :kind => :geometry,
195
+ :geom_kind => kind,
196
+ :geom_srid => srid
197
+ }.merge(opts || {}))
198
+ end
199
+
200
+ def index(*cols)
201
+ opts = cols.delete_at -1 if cols.last.is_a? Hash
202
+ opts ||= {}
203
+ opts[:columns] = cols
204
+ opts[:table_name] = @name
205
+ self << Mystic::SQL::Index.new(opts)
206
+ end
207
+
208
+ def method_missing(meth, *args, &block)
209
+ return column args[0], meth.to_s, args[1] if args.count > 0
210
+ return @opts[meth] if @opts.member?(meth)
211
+ nil
212
+ end
213
+ end
214
+
215
+ class Operation < SQLObject
216
+ attr_reader :kind,
217
+ :callback
218
+
219
+ def initialize(kind, opts={})
220
+ @kind = kind
221
+ @opts = opts.dup
222
+ @callback = @opts.delete :callback
223
+ end
224
+
225
+ def method_missing(meth, *args, &block)
226
+ @opts[meth.to_s.to_sym] rescue nil
227
+ end
228
+
229
+ def self.method_missing(meth, *args, &block)
230
+ new meth, (args[0] || {})
231
+ end
232
+ end
233
+
234
+ class Raw < SQLObject
235
+ def initialize(opts)
236
+ @sql = opts[:sql]
237
+ end
238
+
239
+ def to_sql
240
+ @sql
241
+ end
242
+ end
243
+ end
244
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mystic
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Nathaniel Symer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-07-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: densify
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: access_stack
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: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3'
55
+ description: Database management/access gem. Supports adapters, migrations, and a
56
+ singleton to make SQL queries.
57
+ email: nate@ivytap.com
58
+ executables:
59
+ - mystic
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - LICENSE
64
+ - bin/mystic
65
+ - lib/mystic.rb
66
+ - lib/mystic/adapter.rb
67
+ - lib/mystic/adapters/abstract.rb
68
+ - lib/mystic/adapters/mysql.rb
69
+ - lib/mystic/adapters/postgres.rb
70
+ - lib/mystic/constants.rb
71
+ - lib/mystic/extensions.rb
72
+ - lib/mystic/migration.rb
73
+ - lib/mystic/model.rb
74
+ - lib/mystic/sql.rb
75
+ homepage: https://github.com/ivytap/mystic
76
+ licenses:
77
+ - MIT
78
+ metadata: {}
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubyforge_project:
95
+ rubygems_version: 2.2.2
96
+ signing_key:
97
+ specification_version: 4
98
+ summary: Lightweight migrations + SQL execution
99
+ test_files: []