declare_schema 0.6.4 → 0.7.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 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