declare_schema 0.6.4 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 523660550181d173c4ff4d6dc181b6d790419a3fdfb1e956076eed2d4c9f7f3b
4
- data.tar.gz: '0380d2f800fd64c770c87631090ee792875034211b6ba9d18f5acc34e5ba9f0b'
3
+ metadata.gz: 640e3580d2babdce916a3127b95cd1c84eb8d87b89ca67116496cd3729eea33c
4
+ data.tar.gz: 30b86c95f516f37ce979ac558e64ac84f0150493392a0626495e83e68b5dc14d
5
5
  SHA512:
6
- metadata.gz: 824d98b6bb3b30d0eebf594db7cead5e954fed227eefdb8f627811da3a7f8c9bc76f62552c3c3a87b0ff3adf51a95a1e666e903d6993430aaa3521607b6ee260
7
- data.tar.gz: e8b45fb7dd6df1b314b3ada7e3da6223c631911ce88233165dff486ddb0bfc4a09e42255c018e7d00a456f95d0c1a8af010fe5531f6c2d64516cf3a09f51eb19
6
+ metadata.gz: 60e109345bc7d2551e441a7c5618f69b3e4df28551bc6c7807329f8831eb3090770fc70c2692487495935bd7c3394f83ccb77cfff1370afccb5a33105229f790
7
+ data.tar.gz: 5f4cf408c53d963995795786ea847f6e5e4c7319fe91fd95d8eb6d4c05348ddb6cf1fcbbded15764e9b80af0e0903ec26b984d82c467a3a0a6956e421f7d5ace
data/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
4
4
 
5
5
  Note: this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.7.0] - 2020-02-14
8
+ ### Changed
9
+ - Use `schema_attributes` for generating both up and down change migrations, so they are guaranteed to be symmetrical.
10
+ Note: Rails schema dumper is still used for the down migration to replace a model that has been dropped.
11
+
7
12
  ## [0.6.4] - 2020-02-08
8
13
  - Fixed a bug where the generated call to add_foreign_key() was not setting `column:`,
9
14
  so it only worked in cases where Rails could infer the foreign key by convention.
@@ -114,6 +119,7 @@ using the appropriate Rails configuration attributes.
114
119
  ### Added
115
120
  - Initial version from https://github.com/Invoca/hobo_fields v4.1.0.
116
121
 
122
+ [0.7.0]: https://github.com/Invoca/declare_schema/compare/v0.6.3...v0.7.0
117
123
  [0.6.4]: https://github.com/Invoca/declare_schema/compare/v0.6.3...v0.6.4
118
124
  [0.6.3]: https://github.com/Invoca/declare_schema/compare/v0.6.2...v0.6.3
119
125
  [0.6.2]: https://github.com/Invoca/declare_schema/compare/v0.6.1...v0.6.2
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- declare_schema (0.6.4)
4
+ declare_schema (0.7.0)
5
5
  rails (>= 4.2)
6
6
 
7
7
  GEM
@@ -54,7 +54,7 @@ GEM
54
54
  thor (>= 0.14.0)
55
55
  arel (9.0.0)
56
56
  ast (2.4.1)
57
- bootsnap (1.5.1)
57
+ bootsnap (1.7.2)
58
58
  msgpack (~> 1.0)
59
59
  builder (3.2.4)
60
60
  byebug (11.1.3)
@@ -84,7 +84,7 @@ GEM
84
84
  mini_mime (1.0.2)
85
85
  mini_portile2 (2.4.0)
86
86
  minitest (5.14.2)
87
- msgpack (1.3.3)
87
+ msgpack (1.4.2)
88
88
  nio4r (2.5.4)
89
89
  nokogiri (1.10.10)
90
90
  mini_portile2 (~> 2.4.0)
@@ -175,7 +175,7 @@ GEM
175
175
  websocket-driver (0.7.3)
176
176
  websocket-extensions (>= 0.1.0)
177
177
  websocket-extensions (0.1.5)
178
- yard (0.9.25)
178
+ yard (0.9.26)
179
179
 
180
180
  PLATFORMS
181
181
  ruby
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclareSchema
4
+ class UnknownSqlTypeError < RuntimeError; end
5
+
6
+ module Model
7
+ # This class is a wrapper for the ActiveRecord::...::Column class
8
+ class Column
9
+ class << self
10
+ def native_type?(type)
11
+ type != :primary_key && native_types.has_key?(type)
12
+ end
13
+
14
+ # MySQL example:
15
+ # { primary_key: "bigint auto_increment PRIMARY KEY",
16
+ # string: { name: "varchar", limit: 255 },
17
+ # text: { name: "text", limit: 65535},
18
+ # integer: {name: "int", limit: 4 },
19
+ # float: {name: "float", limit: 24 },
20
+ # decimal: { name: "decimal" },
21
+ # datetime: { name: "datetime" },
22
+ # timestamp: { name: "timestamp" },
23
+ # time: { name: "time" },
24
+ # date: { name: "date" },
25
+ # binary: { name>: "blob", limit: 65535 },
26
+ # boolean: { name: "tinyint", limit: 1 },
27
+ # json: { name: "json" } }
28
+ #
29
+ # SQLite example:
30
+ # { primary_key: "integer PRIMARY KEY AUTOINCREMENT NOT NULL",
31
+ # string: { name: "varchar" },
32
+ # text: { name: "text"},
33
+ # integer: { name: "integer" },
34
+ # float: { name: "float" },
35
+ # decimal: { name: "decimal" },
36
+ # datetime: { name: "datetime" },
37
+ # time: { name: "time" },
38
+ # date: { name: "date" },
39
+ # binary: { name: "blob" },
40
+ # boolean: { name: "boolean" },
41
+ # json: { name: "json" } }
42
+ def native_types
43
+ @native_types ||= ActiveRecord::Base.connection.native_database_types.tap do |types|
44
+ if ActiveRecord::Base.connection.class.name.match?(/mysql/i)
45
+ types[:text][:limit] ||= 0xffff
46
+ types[:binary][:limit] ||= 0xffff
47
+ end
48
+ end
49
+ end
50
+
51
+ def sql_type(type)
52
+ if native_type?(type)
53
+ type
54
+ else
55
+ if (field_class = DeclareSchema.to_class(type))
56
+ field_class::COLUMN_TYPE
57
+ end or raise UnknownSqlTypeError, "#{type.inspect} for type #{type.inspect}"
58
+ end
59
+ end
60
+
61
+ def deserialize_default_value(column, sql_type, default_value)
62
+ sql_type or raise ArgumentError, "must pass sql_type; got #{sql_type.inspect}"
63
+
64
+ case Rails::VERSION::MAJOR
65
+ when 4
66
+ # TODO: Delete this Rails 4 support ASAP! This could be wrong, since it's using the type of the old column...which
67
+ # might be getting migrated to a new type. We should be using just sql_type as below. -Colin
68
+ column.type_cast_from_database(default_value)
69
+ else
70
+ cast_type = ActiveRecord::Base.connection.send(:lookup_cast_type, sql_type) or
71
+ raise "cast_type not found for #{sql_type}"
72
+ cast_type.deserialize(default_value)
73
+ end
74
+ end
75
+
76
+ # Normalizes schema attributes for the specific database adapter that is currently running
77
+ # Note that the un-normalized attributes are still useful for generating migrations because those
78
+ # may be run with a different adapter.
79
+ # This method never mutates its argument. In fact it freezes it to be certain.
80
+ def normalize_schema_attributes(schema_attributes)
81
+ schema_attributes[:type] or raise ArgumentError, ":type key not found; keys: #{schema_attributes.keys.inspect}"
82
+ schema_attributes.freeze
83
+
84
+ case ActiveRecord::Base.connection.class.name
85
+ when /mysql/i
86
+ schema_attributes
87
+ when /sqlite/i
88
+ case schema_attributes[:type]
89
+ when :text
90
+ schema_attributes = schema_attributes.merge(limit: nil)
91
+ when :integer
92
+ schema_attributes = schema_attributes.dup
93
+ schema_attributes[:limit] ||= 8
94
+ end
95
+ schema_attributes
96
+ else
97
+ schema_attributes
98
+ end
99
+ end
100
+
101
+ def equivalent_schema_attributes?(schema_attributes_lhs, schema_attributes_rhs)
102
+ normalize_schema_attributes(schema_attributes_lhs) == normalize_schema_attributes(schema_attributes_rhs)
103
+ end
104
+ end
105
+
106
+ def initialize(model, current_table_name, column)
107
+ @model = model or raise ArgumentError, "must pass model"
108
+ @current_table_name = current_table_name or raise ArgumentError, "must pass current_table_name"
109
+ @column = column or raise ArgumentError, "must pass column"
110
+ end
111
+
112
+ def sql_type
113
+ @sql_type ||= self.class.sql_type(@column.type)
114
+ end
115
+
116
+ SCHEMA_KEYS = [:type, :limit, :precision, :scale, :null, :default].freeze
117
+
118
+ # omits keys with nil values
119
+ def schema_attributes
120
+ SCHEMA_KEYS.each_with_object({}) do |key, result|
121
+ value =
122
+ case key
123
+ when :default
124
+ self.class.deserialize_default_value(@column, sql_type, @column.default)
125
+ else
126
+ col_value = @column.send(key)
127
+ if col_value.nil? && (native_type = self.class.native_types[@column.type])
128
+ native_type[key]
129
+ else
130
+ col_value
131
+ end
132
+ end
133
+
134
+ result[key] = value unless value.nil?
135
+ end.tap do |result|
136
+ if ActiveRecord::Base.connection.class.name.match?(/mysql/i) && @column.type.in?([:string, :text])
137
+ result.merge!(collation_and_charset_for_column(@current_table_name, @column.name))
138
+ end
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def collation_and_charset_for_column(current_table_name, column_name)
145
+ connection = ActiveRecord::Base.connection
146
+ connection.class.name.match?(/mysql/i) or raise ArgumentError, "only supported for MySQL"
147
+
148
+ database_name = connection.current_database
149
+
150
+ defaults = connection.select_one(<<~EOS)
151
+ SELECT C.character_set_name, C.collation_name
152
+ FROM information_schema.`COLUMNS` C
153
+ WHERE C.table_schema = '#{connection.quote_string(database_name)}' AND
154
+ C.table_name = '#{connection.quote_string(current_table_name)}' AND
155
+ C.column_name = '#{connection.quote_string(column_name)}';
156
+ EOS
157
+
158
+ defaults && defaults["character_set_name"] or raise "character_set_name missing from #{defaults.inspect} from #{database_name}.#{current_table_name}.#{column_name}"
159
+ defaults && defaults["collation_name"] or raise "collation_name missing from #{defaults.inspect}"
160
+
161
+ {
162
+ charset: defaults["character_set_name"],
163
+ collation: defaults["collation_name"]
164
+ }
165
+ end
166
+ end
167
+ end
168
+ end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'column'
4
+
3
5
  module DeclareSchema
6
+ class MysqlTextMayNotHaveDefault < RuntimeError; end
7
+
4
8
  module Model
5
9
  class FieldSpec
6
- class UnknownSqlTypeError < RuntimeError; end
7
10
 
8
11
  MYSQL_TINYTEXT_LIMIT = 0xff
9
12
  MYSQL_TEXT_LIMIT = 0xffff
@@ -30,7 +33,18 @@ module DeclareSchema
30
33
  end
31
34
  end
32
35
 
33
- attr_reader :model, :name, :type, :position, :options
36
+ attr_reader :model, :name, :type, :sql_type, :position, :options, :sql_options
37
+
38
+ TYPE_SYNONYMS = { timestamp: :datetime }.freeze # TODO: drop this synonym. -Colin
39
+
40
+ SQL_OPTIONS = [:limit, :precision, :scale, :null, :default, :charset, :collation].freeze
41
+ NON_SQL_OPTIONS = [:ruby_default, :validates].freeze
42
+ VALID_OPTIONS = (SQL_OPTIONS + NON_SQL_OPTIONS).freeze
43
+ OPTION_INDEXES = Hash[VALID_OPTIONS.each_with_index.to_a].freeze
44
+
45
+ VALID_OPTIONS.each do |option|
46
+ define_method(option) { @options[option] }
47
+ end
34
48
 
35
49
  def initialize(model, name, type, position: 0, **options)
36
50
  # TODO: TECH-5116
@@ -42,170 +56,72 @@ module DeclareSchema
42
56
  @model = model
43
57
  @name = name.to_sym
44
58
  type.is_a?(Symbol) or raise ArgumentError, "type must be a Symbol; got #{type.inspect}"
45
- @type = type
59
+ @type = TYPE_SYNONYMS[type] || type
46
60
  @position = position
47
- @options = options
61
+ @options = options.dup
62
+
63
+ @options.has_key?(:null) or @options[:null] = false
64
+
48
65
  case type
49
66
  when :text
50
- @options[:default] and raise "default may not be given for :text field #{model}##{@name}"
51
67
  if self.class.mysql_text_limits?
68
+ @options[:default].nil? or raise MysqlTextMayNotHaveDefault, "when using MySQL, non-nil default may not be given for :text field #{model}##{@name}"
52
69
  @options[:limit] = self.class.round_up_mysql_text_limit(@options[:limit] || MYSQL_LONGTEXT_LIMIT)
70
+ else
71
+ @options[:limit] = nil
53
72
  end
54
73
  when :string
55
- @options[:limit] or raise "limit must be given for :string field #{model}##{@name}: #{@options.inspect}; do you want `limit: 255`?"
74
+ @options[:limit] or raise "limit: must be given for :string field #{model}##{@name}: #{@options.inspect}; do you want `limit: 255`?"
56
75
  when :bigint
57
76
  @type = :integer
58
- @options = options.merge(limit: 8)
77
+ @options[:limit] = 8
59
78
  end
60
79
 
61
- if type.in?([:text, :string])
62
- if ActiveRecord::Base.connection.class.name.match?(/mysql/i)
63
- @options[:charset] ||= model.table_options[:charset] || Generators::DeclareSchema::Migration::Migrator.default_charset
64
- @options[:collation] ||= model.table_options[:collation] || Generators::DeclareSchema::Migration::Migrator.default_collation
65
- end
80
+ # TODO: Do we really need to support a :sql_type option? Ideally, drop it. -Colin
81
+ @sql_type = @options.delete(:sql_type) || Column.sql_type(@type)
82
+
83
+ if @sql_type.in?([:string, :text, :binary, :varbinary, :integer, :enum])
84
+ @options[:limit] ||= Column.native_types[@sql_type][:limit]
66
85
  else
67
- @options[:charset] and raise "charset may only given for :string and :text fields"
68
- @options[:collation] and raise "collation may only given for :string and :text fields"
86
+ @sql_type != :decimal && @options.has_key?(:limit) and warn("unsupported limit: for SQL type #{@sql_type} in field #{model}##{@name}")
87
+ @options.delete(:limit)
69
88
  end
70
- end
71
89
 
72
- TYPE_SYNONYMS = { timestamp: :datetime }.freeze
73
-
74
- SQLITE_COLUMN_CLASS =
75
- begin
76
- ActiveRecord::ConnectionAdapters::SQLiteColumn
77
- rescue NameError
78
- NilClass
90
+ if @sql_type == :decimal
91
+ @options[:precision] or warn("precision: required for :decimal type in field #{model}##{@name}")
92
+ @options[:scale] or warn("scale: required for :decimal type in field #{model}##{@name}")
93
+ else
94
+ if @sql_type != :datetime
95
+ @options.has_key?(:precision) and warn("precision: only allowed for :decimal type or :datetime for SQL type #{@sql_type} in field #{model}##{@name}")
96
+ end
97
+ @options.has_key?(:scale) and warn("scale: only allowed for :decimal type for SQL type #{@sql_type} in field #{model}##{@name}")
79
98
  end
80
99
 
81
- def sql_type
82
- @options[:sql_type] || begin
83
- if native_type?(type)
84
- type
85
- else
86
- field_class = DeclareSchema.to_class(type)
87
- field_class && field_class::COLUMN_TYPE or raise UnknownSqlTypeError, "#{type.inspect} for #{model}##{@name}"
88
- end
89
- end
90
- end
91
-
92
- def sql_options
93
- @options.except(:ruby_default, :validates)
94
- end
95
-
96
- def limit
97
- @options[:limit] || native_types[sql_type][:limit]
98
- end
99
-
100
- def precision
101
- @options[:precision]
102
- end
103
-
104
- def scale
105
- @options[:scale]
106
- end
107
-
108
- def null
109
- !:null.in?(@options) || @options[:null]
110
- end
111
-
112
- def default
113
- @options[:default]
114
- end
115
-
116
- def charset
117
- @options[:charset]
118
- end
119
-
120
- def collation
121
- @options[:collation]
122
- end
123
-
124
- def same_type?(col_spec)
125
- type = sql_type
126
- normalized_type = TYPE_SYNONYMS[type] || type
127
- normalized_col_spec_type = TYPE_SYNONYMS[col_spec.type] || col_spec.type
128
- normalized_type == normalized_col_spec_type
129
- end
130
-
131
- def different_to?(table_name, col_spec)
132
- !same_as(table_name, col_spec)
133
- end
134
-
135
- def same_as(table_name, col_spec)
136
- same_type?(col_spec) &&
137
- same_attributes?(col_spec) &&
138
- (!type.in?([:text, :string]) || same_charset_and_collation?(table_name, col_spec))
139
- end
140
-
141
- private
142
-
143
- def same_attributes?(col_spec)
144
- native_type = native_types[type]
145
- check_attributes = [:null, :default]
146
- check_attributes += [:precision, :scale] if sql_type == :decimal && !col_spec.is_a?(SQLITE_COLUMN_CLASS) # remove when rails fixes https://rails.lighthouseapp.com/projects/8994-ruby-on-rails/tickets/2872
147
- check_attributes -= [:default] if sql_type == :text && col_spec.class.name =~ /mysql/i
148
- check_attributes << :limit if sql_type.in?([:string, :binary, :varbinary, :integer, :enum]) ||
149
- (sql_type == :text && self.class.mysql_text_limits?)
150
- check_attributes.all? do |k|
151
- if k == :default
152
- case Rails::VERSION::MAJOR
153
- when 4
154
- col_spec.type_cast_from_database(col_spec.default) == col_spec.type_cast_from_database(default)
155
- else
156
- cast_type = ActiveRecord::Base.connection.lookup_cast_type_from_column(col_spec) or raise "cast_type not found for #{col_spec.inspect}"
157
- cast_type.deserialize(col_spec.default) == cast_type.deserialize(default)
158
- end
100
+ if @type.in?([:text, :string])
101
+ if ActiveRecord::Base.connection.class.name.match?(/mysql/i)
102
+ @options[:charset] ||= model.table_options[:charset] || Generators::DeclareSchema::Migration::Migrator.default_charset
103
+ @options[:collation] ||= model.table_options[:collation] || Generators::DeclareSchema::Migration::Migrator.default_collation
159
104
  else
160
- col_value = col_spec.send(k)
161
- if col_value.nil? && native_type
162
- col_value = native_type[k]
163
- end
164
- col_value == send(k)
105
+ @options.delete(:charset)
106
+ @options.delete(:collation)
165
107
  end
166
- end
167
- end
168
-
169
- def same_charset_and_collation?(table_name, col_spec)
170
- current_collation_and_charset = collation_and_charset_for_column(table_name, col_spec)
171
-
172
- collation == current_collation_and_charset[:collation] &&
173
- charset == current_collation_and_charset[:charset]
174
- end
175
-
176
- def collation_and_charset_for_column(table_name, col_spec)
177
- column_name = col_spec.name
178
- connection = ActiveRecord::Base.connection
179
-
180
- if connection.class.name.match?(/mysql/i)
181
- database_name = connection.current_database
182
-
183
- defaults = connection.select_one(<<~EOS)
184
- SELECT C.character_set_name, C.collation_name
185
- FROM information_schema.`COLUMNS` C
186
- WHERE C.table_schema = '#{connection.quote_string(database_name)}' AND
187
- C.table_name = '#{connection.quote_string(table_name)}' AND
188
- C.column_name = '#{connection.quote_string(column_name)}';
189
- EOS
190
-
191
- defaults["character_set_name"] or raise "character_set_name missing from #{defaults.inspect}"
192
- defaults["collation_name"] or raise "collation_name missing from #{defaults.inspect}"
193
-
194
- {
195
- charset: defaults["character_set_name"],
196
- collation: defaults["collation_name"]
197
- }
198
108
  else
199
- {}
109
+ @options[:charset] and warn("charset may only given for :string and :text fields for SQL type #{@sql_type} in field #{model}##{@name}")
110
+ @options[:collation] and warne("collation may only given for :string and :text fields for SQL type #{@sql_type} in field #{model}##{@name}")
200
111
  end
201
- end
202
112
 
203
- def native_type?(type)
204
- type.to_sym != :primary_key && native_types.has_key?(type)
113
+ @options = Hash[@options.sort_by { |k, _v| OPTION_INDEXES[k] || 9999 }]
114
+
115
+ @sql_options = @options.except(*NON_SQL_OPTIONS)
205
116
  end
206
117
 
207
- def native_types
208
- Generators::DeclareSchema::Migration::Migrator.native_types
118
+ # returns the attributes for schema migrations as a Hash
119
+ # omits name and position since those are meta-data above the schema
120
+ # omits keys with nil values
121
+ def schema_attributes(col_spec)
122
+ @options.merge(type: @type).tap do |attrs|
123
+ attrs[:default] = Column.deserialize_default_value(col_spec, @sql_type, attrs[:default])
124
+ end.compact
209
125
  end
210
126
  end
211
127
  end