model_schema 0.1.0

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: 1f90013060ee4c6589d5da0f52a6d43d122f1098
4
+ data.tar.gz: e6a196f114bfdfb96f360404cdb7e3b318f43545
5
+ SHA512:
6
+ metadata.gz: 40b269d838239123dad028eb77512fac7299ed248b249daa567243a38c99e64e13d9b465b5eaf97ea8b7f02d8820be1b52b388b48ea0e5de2834ebac6d9d9148
7
+ data.tar.gz: e2d6ffe9929ea0940a7c2e94a29fa47747feb8c03749c8ac49c1385d6f66ffd00044f510d2e98fda5072e5d3d4758c42a8ebfb93abd019f9b4bfaf5e54e7891a
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ .DS_Store
11
+ *.sw[pon]
data/.travis.yml ADDED
@@ -0,0 +1,11 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1.5
4
+ before_install: gem install bundler -v 1.10.6
5
+
6
+ services:
7
+ - postgresql
8
+ before_script:
9
+ - psql -c 'create database model_schema;' -U postgres
10
+ env:
11
+ - DB_URL=postgres://localhost:5432/model_schema
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Karthik Viswanathan
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/README.md ADDED
@@ -0,0 +1,251 @@
1
+ # ModelSchema [![Build Status](https://travis-ci.org/karthikv/model_schema.svg?branch=master)](https://travis-ci.org/karthikv/model_schema)
2
+ ModelSchema lets you annotate a [Sequel](https://github.com/jeremyevans/sequel/)
3
+ Model with its expected schema (columns and indexes). Instead of seeing
4
+ a Sequel Model file that looks like this:
5
+
6
+ ```rb
7
+ class User < Sequel::Model(:users)
8
+ end
9
+ ```
10
+
11
+ You'll see one that looks like this:
12
+
13
+ ```rb
14
+ class User < Sequel::Model(:users)
15
+ model_schema do
16
+ primary_key :id
17
+
18
+ String :email, :null => false
19
+ String :password, :null => false
20
+
21
+ TrueClass :is_admin, :default => false
22
+
23
+ DateTime :created_at, :null => false
24
+ DateTime :updated_at
25
+
26
+ index :email
27
+ end
28
+ end
29
+ ```
30
+
31
+ Unlike other similar gems, ModelSchema provides *enforcement*; if the schema you
32
+ specify doesn't match the table schema, ModelSchema will raise an error and
33
+ tell you exactly what's wrong, like so:
34
+
35
+ ```
36
+ ModelSchema::SchemaError: Table users does not match the expected schema.
37
+
38
+ Table users has extra columns:
39
+
40
+ Integer :age
41
+
42
+ Table users is missing columns:
43
+
44
+ TrueClass :is_admin, :default => false
45
+
46
+ Table users has mismatched indexes:
47
+
48
+ actual: index [:email], :unique => true
49
+ expected: index [:email]
50
+
51
+ You may disable schema checks by passing :disable => true to model_schema or by
52
+ setting the ENV variable DISABLE_MODEL_SCHEMA=1.
53
+ ```
54
+
55
+ When developing on a team, local databases of team members can easily get out
56
+ of sync due to differing migrations. ModelSchema immediately lets you know if
57
+ the schema you expect differs from the actual schema. This ensures you identify
58
+ database inconsistencies before they cause problems. As a nice added benefit,
59
+ ModelSchema lets you see a list of columns for a model directly within the
60
+ class itself.
61
+
62
+ ## Support for ActiveRecord
63
+ Currently, ModelSchema only works with [Sequel](https://github.com/jeremyevans/sequel/).
64
+ If you'd like something similar for other Ruby ORMs, like ActiveRecord, please
65
+ express your interest in [this issue](https://github.com/karthikv/model_schema/issues/1).
66
+
67
+ ## Installation
68
+ Add `model_schema` to your Gemfile:
69
+
70
+ ```rb
71
+ gem 'model_schema'
72
+ ```
73
+
74
+ And then execute `bundle` in your terminal. You can also install `model_schema`
75
+ with `gem` directly by running `gem install model_schema`.
76
+
77
+ ## Usage
78
+ Require `model_schema` and register the plugin with Sequel:
79
+
80
+ ```rb
81
+ require 'model_schema'
82
+ Sequel.plugin(ModelSchema::Plugin)
83
+ ```
84
+
85
+ Then, in each model where you'd like to use ModelSchema, introduce a call to
86
+ `model_schema`, passing in a block that defines the schema. The block operates
87
+ exactly as a [Sequel `create_table`
88
+ block](http://sequel.jeremyevans.net/rdoc/files/doc/schema_modification_rdoc.html).
89
+ See the documentation on that page for further details.
90
+
91
+ ```rb
92
+ class Post < Sequel::Model(:posts)
93
+ model_schema do
94
+ primary_key :id
95
+
96
+ String :title, :null => false
97
+ String :description, :text => true, :null => false
98
+ DateTime :date_posted, :null => false
99
+ end
100
+ end
101
+ ```
102
+
103
+ When the class is loaded, ModelSchema will ensure the table schema matches the
104
+ given schema. If there are any errors, it will raise
105
+ a `ModelSchema::SchemaError`, notifying you of any inconsistencies.
106
+
107
+ You may pass an optional hash to `model_schema` with the following options:
108
+
109
+ `disable`: `true` to disable all schema checks, `false` otherwise
110
+ `no_indexes`: `true` to disable schema checks for indexes (columns will still
111
+ be checked), `false` otherwise
112
+
113
+ For instance, to disable index checking:
114
+
115
+ ```rb
116
+ class Item < Sequel::Model(:items)
117
+ model_schema(:no_indexes => true) do
118
+ ...
119
+ end
120
+ end
121
+ ```
122
+
123
+ Note that you can disable ModelSchema in two ways: either pass `:disable =>
124
+ true` to the `model_schema` method, or set the environment variable
125
+ `DISABLE_MODEL_SCHEMA=1` .
126
+
127
+ ## Bootstrap Existing Project
128
+ To help bootstrap existing projects that don't yet use ModelSchema, you can use
129
+ the `dump_model_schema` executable. It will automatically dump an up-to-date
130
+ `model_schema` block in each Sequel Model class. Use it like so:
131
+
132
+ ```sh
133
+ $ dump_model_schema -m [model_file] -c [connection_string]
134
+ ```
135
+
136
+ where `model_file` is a path to a ruby file that contains a single Sequel
137
+ Model, and `connection_string` is the database connection string to pass to
138
+ `Sequel.connect()`.
139
+
140
+ `dump_model_schema` will insert a `model_schema` block right after the
141
+ definition of the Sequel Model class. Specifically, it looks for a line of the
142
+ form `class SomeClassName < Sequel::Model(:table_name)`, and inserts a valid
143
+ schema for table `table_name` directly after that line. Note that
144
+ `dump_model_schema` overwrites the model file.
145
+
146
+ For instance, say you had a file `items.rb` that looks like this:
147
+
148
+ ```rb
149
+ module SomeModule
150
+ class Item < Sequel::Model(:items)
151
+ end
152
+ end
153
+ ```
154
+
155
+ If you run:
156
+
157
+ ```sh
158
+ $ dump_model_schema -m items.rb -c [connection_string]
159
+ ```
160
+
161
+ `items.rb` might now look like:
162
+
163
+ ```rb
164
+ module SomeModule
165
+ class Item < Sequel::Model(:items)
166
+ model_schema do
167
+ primary_key :id,
168
+
169
+ String :name, :null => false
170
+ Integer :quantity
171
+
172
+ index [:name], :name => :items_name_key
173
+ end
174
+ end
175
+ end
176
+ ```
177
+
178
+ By default, `dump_model_schema` assumes a tab size of 2 spaces, but you can
179
+ change this with the `-t` option. Pass an integer representing the number of
180
+ spaces, or 0 if you want to use hard tabs.
181
+
182
+ You may see help text with `dump_model_schema -h` and view the version of
183
+ ModelSchema with `dump_model_schema -v`.
184
+
185
+ ## Limitations
186
+ ModelSchema has a few notable limitations:
187
+
188
+ - It checks columns independently from indexes. Say you create a table like so:
189
+
190
+ ```rb
191
+ DB.create_table(:items) do
192
+ String :name, :unique => true
193
+ Integer :value, :index => true
194
+ end
195
+ ```
196
+
197
+ The corresponding `model_schema` block would be:
198
+
199
+ ```rb
200
+ class Item < Sequel::Model(:items)
201
+ model_schema do
202
+ String :name
203
+ Integer :value
204
+
205
+ index :name, :unique => true
206
+ index :value
207
+ end
208
+ end
209
+ ```
210
+
211
+ You have to separate the columns from the indexes, since the schema dumper
212
+ reads them independently of one another.
213
+
214
+ - It relies on Sequel's [schema dumper extension](http://sequel.jeremyevans.net/rdoc/files/doc/migration_rdoc.html#label-Dumping+the+current+schema+as+a+migration)
215
+ to read your table's schema. The schema dumper doesn't read constraints,
216
+ triggers, special index types (e.g. gin, gist) or partial indexes; you'll
217
+ have to omit these from your `model_schema` block.
218
+
219
+ - It doesn't handle all type aliasing. For instance, the Postgres types
220
+ `character varying(255)[]` and `varchar(255)[]` are equivalent, but
221
+ ModelSchema is unaware of this. In turn, you might see this error message:
222
+
223
+ ```
224
+ Table complex has mismatched columns:
225
+
226
+ actual: column :advisors, "character varying(255)[]", :null=>false
227
+ expected: column :advisors, "varchar(255)[]", :null=>false
228
+ ```
229
+
230
+ In the above case, you'll need to change `varchar(255)[]` to `character
231
+ varying(255)[]` in your `model_schema` block to fix the issue.
232
+
233
+ A similar problem occurs with `numeric(x, 0)` and `numeric(x)`, where x is an
234
+ integer; they are equivalent in Postgres, but ModelSchema doesn't know this.
235
+
236
+ ## Development and Contributing
237
+ After cloning this repository, execute `bundle` to install dependencies. You
238
+ may run tests with `rake test`, and open up a REPL using `bin/repl`.
239
+
240
+ Note that tests require access to a Postgres database. Set the environment
241
+ variable `DB_URL` to a Postgres connection string (e.g.
242
+ `postgres://localhost:5432/model_schema`) prior to running tests. See
243
+ [connecting to a database](http://sequel.jeremyevans.net/rdoc/files/doc/opening_databases_rdoc.html)
244
+ for information about connection strings.
245
+
246
+ To install this gem onto your local machine, run `bundle exec rake install`.
247
+
248
+ Any bug reports and pull requests are welcome.
249
+
250
+ ## License
251
+ See the [LICENSE.txt](LICENSE.txt) file.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env ruby
2
+ require 'bundler/setup'
3
+ require 'model_schema'
4
+ require 'slop'
5
+
6
+ SEQUEL_MODEL_REGEX = /class\s+.*?<\s+Sequel::Model\((.*?)\)/
7
+ LEADING_WHITESPACE_REGEX = /^\s*/
8
+
9
+ # Parses options and then dumps the model schema.
10
+ def main(args)
11
+ opts = {}
12
+ opts[:tabbing] = 2
13
+
14
+ parser = OptionParser.new do |p|
15
+ p.banner = "Usage: dump_model_schema [options]"
16
+
17
+ p.on('-m', '--model MODEL', 'Model file to dump schema in') do |model|
18
+ opts[:model] = model
19
+ end
20
+
21
+ p.on('-c', '--connection CONNECTION',
22
+ 'Connection string for database') do |connection|
23
+ opts[:connection] = connection
24
+ end
25
+
26
+ p.on('-t', '--tabbing TABBING', Integer,
27
+ 'Number of spaces for tabbing, or 0 for hard tabs') do |tabbing|
28
+ opts[:tabbing] = tabbing
29
+ end
30
+
31
+ p.on('-v', '--version', 'Print version') do
32
+ puts ModelSchema::VERSION
33
+ exit
34
+ end
35
+
36
+ p.on('-h', '--help', 'Print help') do
37
+ puts parser
38
+ exit
39
+ end
40
+ end
41
+
42
+ parser.parse(args)
43
+
44
+ # model and connection are required
45
+ abort 'Must provide a model file with -m or --model.' if !opts[:model]
46
+ abort 'Must provide a connection string with -c or --connection.' if !opts[:connection]
47
+
48
+ dump_model_schema(opts)
49
+ end
50
+
51
+ # Dumps the model schema based on the given options (see option parsing above).
52
+ def dump_model_schema(opts)
53
+ model_info = parse_model_file(opts[:model])
54
+ abort "Couldn't find class that extends Sequel::Model" if !model_info
55
+
56
+ db = Sequel.connect(opts[:connection])
57
+ db.extension(:schema_dumper)
58
+
59
+ klass = Class.new(Sequel::Model(model_info[:table_name]))
60
+ klass.db = db
61
+
62
+ # dump table generator given by model_schema
63
+ generator = klass.send(:table_generator)
64
+ commands = [generator.dump_columns, generator.dump_constraints,
65
+ generator.dump_indexes].reject{|s| s == ''}.join("\n\n")
66
+
67
+ # account for indentation
68
+ tab = opts[:tabbing] == 0 ? "\t" : ' ' * opts[:tabbing]
69
+ schema_indentation = model_info[:indentation] + tab
70
+ command_indentation = schema_indentation + tab
71
+
72
+ commands = commands.lines.map {|l| l == "\n" ? l : command_indentation + l}.join
73
+ commands = commands.gsub('=>', ' => ')
74
+
75
+ dump_lines = ["#{schema_indentation}model_schema do\n",
76
+ "#{commands}\n",
77
+ "#{schema_indentation}end\n"]
78
+
79
+ lines = model_info[:lines_before] + dump_lines + model_info[:lines_after]
80
+ File.write(opts[:model], lines.join)
81
+ end
82
+
83
+ # Parses the model file at the given path, returning a hash of the form:
84
+ #
85
+ # :table_name => the model table name
86
+ # :lines_before => an array of lines before the expected model schema dump
87
+ # :lines_after => an array of lines after the expected model schema dump
88
+ # :indentation => the indentation (leading whitespace) of the model class
89
+ #
90
+ # Returns nil if the file couldn't be parsed.
91
+ def parse_model_file(path)
92
+ lines = File.read(path).lines
93
+
94
+ lines.each_with_index do |line, index|
95
+ match = SEQUEL_MODEL_REGEX.match(line)
96
+
97
+ if match
98
+ # extract table name as symbol
99
+ table_name = match[1]
100
+ if table_name[0] == ':'
101
+ table_name = table_name[1..-1].to_sym
102
+ else
103
+ abort "Can't find a symbol table name on line: #{line}"
104
+ end
105
+
106
+ # indentation for model_schema block
107
+ indentation = LEADING_WHITESPACE_REGEX.match(line)[0]
108
+
109
+ return {
110
+ :table_name => table_name.to_sym,
111
+ :lines_before => lines[0..index],
112
+ :lines_after => lines[(index + 1)..-1],
113
+ :indentation => indentation,
114
+ }
115
+ end
116
+ end
117
+
118
+ nil
119
+ end
120
+
121
+ main(ARGV) if $PROGRAM_NAME == __FILE__
data/bin/repl ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ require 'bundler/setup'
3
+ require 'model_schema'
4
+
5
+ require 'pry'
6
+ Pry.start
@@ -0,0 +1,6 @@
1
+ module ModelSchema; end
2
+
3
+ require 'model_schema/version'
4
+ require 'model_schema/constants'
5
+ require 'model_schema/schema_error'
6
+ require 'model_schema/plugin'
@@ -0,0 +1,42 @@
1
+ module ModelSchema
2
+ # ENV variable name to disable schema checks.
3
+ DISABLE_MODEL_SCHEMA_KEY = 'DISABLE_MODEL_SCHEMA'
4
+
5
+ # field types representing table columns and table indexes
6
+ FIELD_COLUMNS = :columns
7
+ FIELD_INDEXES = :indexes
8
+ FIELDS = [FIELD_COLUMNS, FIELD_INDEXES]
9
+
10
+ # default column parameters
11
+ DEFAULT_COL = {
12
+ :name => nil,
13
+ :type => nil,
14
+ :collate => nil,
15
+ :default => nil,
16
+ :deferrable => nil,
17
+ :index => nil,
18
+ :key => [:id],
19
+ :null => nil,
20
+ :on_delete => :no_action,
21
+ :on_update => :no_action,
22
+ :primary_key => nil,
23
+ :primary_key_constraint_name => nil,
24
+ :unique => nil,
25
+ :unique_constraint_name => nil,
26
+ :serial => nil,
27
+ :table => nil,
28
+ :text => nil,
29
+ :fixed => nil,
30
+ :size => nil,
31
+ :only_time => nil,
32
+ }
33
+
34
+ # default index parameters
35
+ DEFAULT_INDEX = {
36
+ :columns => nil,
37
+ :name => nil,
38
+ :type => nil,
39
+ :unique => nil,
40
+ :where => nil,
41
+ }
42
+ end
@@ -0,0 +1,216 @@
1
+ require 'sequel'
2
+
3
+ module ModelSchema
4
+ # Allows you to define an expected schema for a Sequel::Model class and fail
5
+ # if that schema is not met.
6
+ module Plugin
7
+ module ClassMethods
8
+ # Checks if the model's table schema matches the schema specified by the
9
+ # given block. Raises a SchemaError if this isn't the case.
10
+ #
11
+ # options:
12
+ # :disable => true to disable schema checks;
13
+ # you may also set the ENV variable DISABLE_MODEL_SCHEMA=1
14
+ # :no_indexes => true to disable index checks
15
+ def model_schema(options={}, &block)
16
+ return if ENV[DISABLE_MODEL_SCHEMA_KEY] == '1' || options[:disable]
17
+ db.extension(:schema_dumper)
18
+
19
+ # table generators are Sequel's way of representing schemas
20
+ db_generator = table_generator
21
+ exp_generator = db.create_table_generator(&block)
22
+
23
+ schema_errors = check_all(FIELD_COLUMNS, db_generator, exp_generator)
24
+ if !options[:no_indexes]
25
+ schema_errors += check_all(FIELD_INDEXES, db_generator, exp_generator)
26
+ end
27
+
28
+ raise SchemaError.new(table_name, schema_errors) if schema_errors.length > 0
29
+ end
30
+
31
+ private
32
+
33
+ # Returns the table generator representing this table.
34
+ def table_generator
35
+ begin
36
+ db_generator_explicit = db.send(:dump_table_generator, table_name,
37
+ :same_db => true)
38
+ db_generator_generic = db.send(:dump_table_generator, table_name)
39
+ rescue Sequel::DatabaseError => error
40
+ if error.message.include?('PG::UndefinedTable:')
41
+ fail NameError, "Table #{table_name} doesn't exist."
42
+ end
43
+ end
44
+
45
+ # db_generator_explicit contains explicit string types for each field,
46
+ # specific to the current database; db_generator_generic contains ruby
47
+ # types for each field. When there's no corresponding ruby type,
48
+ # db_generator_generic defaults to the String type. We'd like to
49
+ # combine db_generator_explicit and db_generator_generic into one
50
+ # generator, where ruby types are used if they are accurate. If there
51
+ # is no accurate ruby type, we use the explicit database type. This
52
+ # gives us cleaner column dumps, as ruby types have a better, more
53
+ # generic interface (e.g. `String :col_name` as opposed to
54
+ # `column :col_name, 'varchar(255)`).
55
+
56
+ # start with db_generator_generic, and correct as need be
57
+ db_generator = db_generator_generic.dup
58
+
59
+ # avoid using Sequel::Model.db_schema because it has odd caching
60
+ # behavior across classes that breaks tests
61
+ db.schema(table_name).each do |name, col_schema|
62
+ type_hash = db.column_schema_to_ruby_type(col_schema)
63
+
64
+ if type_hash == {:type => String}
65
+ # There's no corresponding ruby type, as per:
66
+ # <https://github.com/jeremyevans/sequel/blob/a2cfbb9/lib/sequel/
67
+ # extensions/schema_dumper.rb#L59-L61>
68
+ # Copy over the column from db_generator_explicit.
69
+ index = db_generator.columns.find_index {|c| c[:name] == name}
70
+ col = db_generator_explicit.columns.find {|c| c[:name] == name}
71
+ db_generator.columns[index] = col
72
+ end
73
+ end
74
+
75
+ db_generator
76
+ end
77
+
78
+ # Check if db_generator and exp_generator match for the given field
79
+ # (FIELD_COLUMNS for columns or FIELD_INDEXES for indexes).
80
+ def check_all(field, db_generator, exp_generator)
81
+ # To find an accurate diff, we perform two passes on exp_array. In the
82
+ # first pass, we find perfect matches between exp_array and db_array,
83
+ # deleting the corresponding elements. In the second pass, for each
84
+ # exp_elem in exp_array, we find the closest db_elem in db_array that
85
+ # matches it. We then add a mismatch diff between db_elem and exp_elem
86
+ # and remove db_elem from db_array. If no db_elem is deemed close
87
+ # enough, we add a missing diff for exp_elem. Finally, we add an extra
88
+ # diff for each remaining db_elem in db_array.
89
+
90
+ # don't modify original arrays
91
+ db_array = db_generator.send(field).dup
92
+ exp_array = exp_generator.send(field).dup
93
+
94
+ # first pass: remove perfect matches
95
+ exp_array.select! do |exp_elem|
96
+ diffs = db_array.map do |db_elem|
97
+ check_single(field, :db_generator => db_generator,
98
+ :exp_generator => exp_generator,
99
+ :db_elem => db_elem,
100
+ :exp_elem => exp_elem)
101
+ end
102
+
103
+ index = diffs.find_index(nil)
104
+ if index
105
+ # found perfect match; delete elem so it won't be matched again
106
+ db_array.delete_at(index)
107
+ false # we've accounted for this element
108
+ else
109
+ true # we still need to account for this element
110
+ end
111
+ end
112
+
113
+ schema_diffs = []
114
+
115
+ # second pass: find diffs
116
+ exp_array.each do |exp_elem|
117
+ index = find_close_match(field, exp_elem, db_array)
118
+
119
+ if index
120
+ # add mismatch diff between exp_elem and db_array[index]
121
+ schema_diffs << check_single(field, :db_generator => db_generator,
122
+ :exp_generator => exp_generator,
123
+ :db_elem => db_array[index],
124
+ :exp_elem => exp_elem)
125
+ db_array.delete_at(index)
126
+ else
127
+ # add missing diff, since no db_elem is deemed close enough
128
+ schema_diffs << {:field => field,
129
+ :type => SchemaError::TYPE_MISSING,
130
+ :generator => exp_generator,
131
+ :elem => exp_elem}
132
+ end
133
+ end
134
+
135
+ # because we deleted as we went on, db_array holds extra elements
136
+ db_array.each do |db_elem|
137
+ schema_diffs << {:field => field,
138
+ :type => SchemaError::TYPE_EXTRA,
139
+ :generator => db_generator,
140
+ :elem => db_elem}
141
+ end
142
+
143
+ schema_diffs
144
+ end
145
+
146
+ # Returns the index of an element in db_array that closely matches exp_elem,
147
+ # or nil if no such element exists.
148
+ def find_close_match(field, exp_elem, db_array)
149
+ case field
150
+ when FIELD_COLUMNS
151
+ db_array.find_index {|e| e[:name] == exp_elem[:name]}
152
+ when FIELD_INDEXES
153
+ db_array.find_index do |e|
154
+ e[:name] == exp_elem[:name] || e[:columns] == exp_elem[:columns]
155
+ end
156
+ end
157
+ end
158
+
159
+ # Check if the given database element matches the expected element.
160
+ #
161
+ # field: FIELD_COLUMNS for columns or FIELD_INDEXES for indexes
162
+ # opts:
163
+ # :db_generator => db table generator
164
+ # :exp_generator => expected table generator
165
+ # :db_elem => column, constraint, or index from db_generator
166
+ # :exp_elem => column, constraint, or index from exp_generator
167
+ def check_single(field, opts)
168
+ db_generator, exp_generator = opts.values_at(:db_generator, :exp_generator)
169
+ db_elem, exp_elem = opts.values_at(:db_elem, :exp_elem)
170
+
171
+ error = {:field => field,
172
+ :type => SchemaError::TYPE_MISMATCH,
173
+ :db_generator => db_generator,
174
+ :exp_generator => exp_generator,
175
+ :db_elem => db_elem,
176
+ :exp_elem => exp_elem}
177
+
178
+ # db_elem and exp_elem now have the same keys; compare then
179
+ case field
180
+ when FIELD_COLUMNS
181
+ db_elem_defaults = DEFAULT_COL.merge(db_elem)
182
+ exp_elem_defaults = DEFAULT_COL.merge(exp_elem)
183
+ return error if db_elem_defaults.length != exp_elem_defaults.length
184
+
185
+ type_literal = db.method(:type_literal)
186
+ # already accounted for in type check
187
+ keys_accounted_for = [:text, :fixed, :size, :serial]
188
+
189
+ match = db_elem_defaults.all? do |key, value|
190
+ if key == :type
191
+ # types could either be strings or ruby types; normalize them
192
+ db_type = type_literal.call(db_elem_defaults).to_s
193
+ exp_type = type_literal.call(exp_elem_defaults).to_s
194
+ db_type == exp_type
195
+ elsif keys_accounted_for.include?(key)
196
+ true
197
+ else
198
+ value == exp_elem_defaults[key]
199
+ end
200
+ end
201
+
202
+ when FIELD_INDEXES
203
+ db_elem_defaults = DEFAULT_INDEX.merge(db_elem)
204
+ exp_elem_defaults = DEFAULT_INDEX.merge(exp_elem)
205
+ return error if db_elem_defaults.length != exp_elem_defaults.length
206
+
207
+ # if no index name is specified, accept any name
208
+ db_elem_defaults.delete(:name) if !exp_elem_defaults[:name]
209
+ match = db_elem_defaults.all? {|key, value| value == exp_elem_defaults[key]}
210
+ end
211
+
212
+ match ? nil : error
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,112 @@
1
+ module ModelSchema
2
+ # Tracks differences between the expected schema and database table schema.
3
+ class SchemaError < StandardError
4
+ TYPE_EXTRA = :extra
5
+ TYPE_MISSING = :missing
6
+ TYPE_MISMATCH = :mismatch
7
+
8
+ attr_reader :schema_diffs
9
+
10
+ # Creates a SchemaError for the given table with an array of schema
11
+ # differences. Each element of schema_diffs should be a hash of the
12
+ # following form:
13
+ #
14
+ # :field => if a column is different, use FIELD_COLUMNS;
15
+ # if an index is different, use FIELD_INDEXES
16
+ # :type => if there's an extra column/index, use TYPE_EXTRA;
17
+ # if there's a missing column/index, use TYPE_MISSING;
18
+ # if there's a mismatched column/index, use TYPE_MISMATCH
19
+ #
20
+ # For TYPE_EXTRA and TYPE_MISSING:
21
+ # :generator => the table generator that contains the extra/missing index/column
22
+ # :elem => the missing index/column, as a hash from the table generator
23
+ #
24
+ # For TYPE_MISMATCH:
25
+ # :db_generator => the db table generator
26
+ # :exp_generator => the expected table generator
27
+ # :db_elem => the index/column in the db table generator as a hash
28
+ # :exp_elem => the index/column in the exp table generator as a hash
29
+ def initialize(table_name, schema_diffs)
30
+ @table_name = table_name
31
+ @schema_diffs = schema_diffs
32
+ end
33
+
34
+ # Dumps a single column/index from the generator to its string representation.
35
+ #
36
+ # field: FIELD_COLUMNS for a column or FIELD_INDEXES for an index
37
+ # generator: the table generator
38
+ # elem: the index/column in the generator as a hash
39
+ def dump_single(field, generator, elem)
40
+ array = generator.send(field)
41
+ index = array.find_index(elem)
42
+ fail ArgumentError, "#{elem.inspect} not part of #{array.inspect}" if !index
43
+
44
+ lines = generator.send(:"dump_#{field}").lines.map(&:strip)
45
+ lines[index]
46
+ end
47
+
48
+ # Returns the diffs in schema_diffs that have the given field and type.
49
+ def diffs_by_field_type(field, type)
50
+ @schema_diffs.select {|diff| diff[:field] == field && diff[:type] == type}
51
+ end
52
+
53
+ # Dumps all diffs that have the given field and are of TYPE_EXTRA.
54
+ def dump_extra_diffs(field)
55
+ extra_diffs = diffs_by_field_type(field, TYPE_EXTRA)
56
+
57
+ if extra_diffs.length > 0
58
+ header = "Table #{@table_name} has extra #{field}:\n"
59
+ diff_str = extra_diffs.map do |diff|
60
+ dump_single(field, diff[:generator], diff[:elem])
61
+ end.join("\n\t")
62
+
63
+ "#{header}\n\t#{diff_str}\n"
64
+ end
65
+ end
66
+
67
+ # Dumps all diffs that have the given field and are of TYPE_MISSING.
68
+ def dump_missing_diffs(field)
69
+ missing_diffs = diffs_by_field_type(field, TYPE_MISSING)
70
+
71
+ if missing_diffs.length > 0
72
+ header = "Table #{@table_name} is missing #{field}:\n"
73
+ diff_str = missing_diffs.map do |diff|
74
+ dump_single(field, diff[:generator], diff[:elem])
75
+ end.join("\n\t")
76
+
77
+ "#{header}\n\t#{diff_str}\n"
78
+ end
79
+ end
80
+
81
+ # Dumps all diffs that have the given field and are of TYPE_MISMATCH.
82
+ def dump_mismatch_diffs(field)
83
+ mismatch_diffs = diffs_by_field_type(field, TYPE_MISMATCH)
84
+
85
+ if mismatch_diffs.length > 0
86
+ header = "Table #{@table_name} has mismatched #{field}:\n"
87
+ diff_str = mismatch_diffs.map do |diff|
88
+ "actual: #{dump_single(field, diff[:db_generator], diff[:db_elem])}\n\t" +
89
+ "expected: #{dump_single(field, diff[:exp_generator], diff[:exp_elem])}"
90
+ end.join("\n\n\t")
91
+
92
+ "#{header}\n\t#{diff_str}\n"
93
+ end
94
+ end
95
+
96
+ # Combines all dumps into one cohesive error message.
97
+ def to_s
98
+ parts = FIELDS.flat_map do |field|
99
+ [dump_extra_diffs(field),
100
+ dump_missing_diffs(field),
101
+ dump_mismatch_diffs(field)]
102
+ end
103
+
104
+ [
105
+ "Table #{@table_name} does not match the expected schema.\n\n",
106
+ parts.compact.join("\n"),
107
+ "\nYou may disable schema checks by passing :disable => true to model_",
108
+ "schema or by setting the ENV variable #{DISABLE_MODEL_SCHEMA_KEY}=1.\n"
109
+ ].join
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,3 @@
1
+ module ModelSchema
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'model_schema/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'model_schema'
8
+ spec.version = ModelSchema::VERSION
9
+ spec.authors = ['Karthik Viswanathan']
10
+ spec.email = ['karthik.ksv@gmail.com']
11
+
12
+ spec.summary = %(Enforced, Annotated Schema for Ruby Sequel Models)
13
+ spec.description = %(Annotate a Sequel Model with its expected schema
14
+ and immediately identify inconsistencies.).gsub(/\n\s+/, '')
15
+ spec.homepage = 'https://github.com/karthikv/model_schema'
16
+ spec.license = 'MIT'
17
+
18
+ spec.files = `git ls-files -z`.split("\x0").reject {|f| f.match(%r{^(test|spec|features)/})}
19
+ spec.bindir = 'bin'
20
+ spec.executables << 'dump_model_schema'
21
+ spec.require_paths = ['lib']
22
+
23
+ spec.add_development_dependency('bundler', '~> 1.10')
24
+ spec.add_development_dependency('rake', '~> 10.0')
25
+ spec.add_development_dependency('minitest')
26
+ spec.add_development_dependency('minitest-hooks')
27
+ spec.add_development_dependency('mocha')
28
+ spec.add_development_dependency('pg')
29
+ spec.add_development_dependency('pry')
30
+ spec.add_development_dependency('awesome_print')
31
+
32
+ spec.add_runtime_dependency('sequel')
33
+ end
metadata ADDED
@@ -0,0 +1,186 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: model_schema
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Karthik Viswanathan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-11-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.10'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.10'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest-hooks
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: mocha
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pg
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: pry
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: awesome_print
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: sequel
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description: Annotate a Sequel Model with its expected schema and immediately identify
140
+ inconsistencies.
141
+ email:
142
+ - karthik.ksv@gmail.com
143
+ executables:
144
+ - dump_model_schema
145
+ extensions: []
146
+ extra_rdoc_files: []
147
+ files:
148
+ - ".gitignore"
149
+ - ".travis.yml"
150
+ - Gemfile
151
+ - LICENSE.txt
152
+ - README.md
153
+ - Rakefile
154
+ - bin/dump_model_schema
155
+ - bin/repl
156
+ - lib/model_schema.rb
157
+ - lib/model_schema/constants.rb
158
+ - lib/model_schema/plugin.rb
159
+ - lib/model_schema/schema_error.rb
160
+ - lib/model_schema/version.rb
161
+ - model_schema.gemspec
162
+ homepage: https://github.com/karthikv/model_schema
163
+ licenses:
164
+ - MIT
165
+ metadata: {}
166
+ post_install_message:
167
+ rdoc_options: []
168
+ require_paths:
169
+ - lib
170
+ required_ruby_version: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - ">="
173
+ - !ruby/object:Gem::Version
174
+ version: '0'
175
+ required_rubygems_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
180
+ requirements: []
181
+ rubyforge_project:
182
+ rubygems_version: 2.2.2
183
+ signing_key:
184
+ specification_version: 4
185
+ summary: Enforced, Annotated Schema for Ruby Sequel Models
186
+ test_files: []