declare_schema 0.6.0 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/declare_schema_build.yml +21 -5
- data/Appraisals +21 -4
- data/CHANGELOG.md +40 -0
- data/Gemfile +1 -2
- data/Gemfile.lock +7 -9
- data/README.md +3 -3
- data/Rakefile +17 -4
- data/bin/declare_schema +1 -1
- data/declare_schema.gemspec +1 -1
- data/gemfiles/rails_4_mysql.gemfile +22 -0
- data/gemfiles/{rails_4.gemfile → rails_4_sqlite.gemfile} +1 -2
- data/gemfiles/rails_5_mysql.gemfile +22 -0
- data/gemfiles/{rails_5.gemfile → rails_5_sqlite.gemfile} +1 -2
- data/gemfiles/rails_6_mysql.gemfile +22 -0
- data/gemfiles/{rails_6.gemfile → rails_6_sqlite.gemfile} +2 -3
- data/lib/declare_schema/command.rb +10 -3
- data/lib/declare_schema/model/column.rb +168 -0
- data/lib/declare_schema/model/field_spec.rb +59 -143
- data/lib/declare_schema/model/foreign_key_definition.rb +36 -25
- data/lib/declare_schema/model/table_options_definition.rb +8 -6
- data/lib/declare_schema/version.rb +1 -1
- data/lib/generators/declare_schema/migration/migration_generator.rb +1 -1
- data/lib/generators/declare_schema/migration/migrator.rb +142 -116
- data/spec/lib/declare_schema/field_declaration_dsl_spec.rb +1 -1
- data/spec/lib/declare_schema/field_spec_spec.rb +135 -38
- data/spec/lib/declare_schema/generator_spec.rb +4 -2
- data/spec/lib/declare_schema/interactive_primary_key_spec.rb +8 -2
- data/spec/lib/declare_schema/migration_generator_spec.rb +277 -171
- data/spec/lib/declare_schema/model/column_spec.rb +141 -0
- data/spec/lib/declare_schema/model/foreign_key_definition_spec.rb +93 -0
- data/spec/lib/declare_schema/model/index_definition_spec.rb +4 -5
- data/spec/lib/declare_schema/model/table_options_definition_spec.rb +19 -29
- data/spec/lib/generators/declare_schema/migration/migrator_spec.rb +12 -26
- data/spec/support/acceptance_spec_helpers.rb +3 -3
- metadata +15 -9
@@ -0,0 +1,141 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails'
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'mysql2'
|
7
|
+
rescue LoadError
|
8
|
+
end
|
9
|
+
|
10
|
+
require_relative '../../../../lib/declare_schema/model/column'
|
11
|
+
|
12
|
+
RSpec.describe DeclareSchema::Model::Column do
|
13
|
+
before do
|
14
|
+
load File.expand_path('../prepare_testapp.rb', __dir__)
|
15
|
+
end
|
16
|
+
|
17
|
+
describe 'class methods' do
|
18
|
+
describe '.native_type?' do
|
19
|
+
if Rails::VERSION::MAJOR >= 5
|
20
|
+
let(:native_types) { [:string, :text, :integer, :float, :decimal, :datetime, :time, :date, :binary, :boolean, :json] }
|
21
|
+
else
|
22
|
+
let(:native_types) { [:string, :text, :integer, :float, :decimal, :datetime, :time, :date, :binary, :boolean] }
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'is falsey for :primary_key' do
|
26
|
+
expect(described_class.native_type?(:primary_key)).to be_falsey
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'is truthy for native types' do
|
30
|
+
native_types.each do |type|
|
31
|
+
expect(described_class.native_type?(type)).to be_truthy, type.inspect
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'is falsey for other types' do
|
36
|
+
[:email, :url].each do |type|
|
37
|
+
expect(described_class.native_type?(type)).to be_falsey
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe '.native_types' do
|
43
|
+
subject { described_class.native_types }
|
44
|
+
|
45
|
+
it 'returns the native type for :primary_key' do
|
46
|
+
expect(subject[:primary_key]).to match(/auto_increment PRIMARY KEY|PRIMARY KEY AUTOINCREMENT NOT NULL/)
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'returns the native type for :string' do
|
50
|
+
expect(subject.dig(:string, :name)).to eq('varchar')
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'returns the native type for :integer' do
|
54
|
+
expect(subject.dig(:integer, :name)).to match(/int/)
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'returns the native type for :datetime' do
|
58
|
+
expect(subject.dig(:datetime, :name)).to eq('datetime')
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe '.sql_type' do
|
63
|
+
it 'returns the sql type for :string' do
|
64
|
+
expect(described_class.sql_type(:string)).to eq(:string)
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'returns the sql type for :integer' do
|
68
|
+
expect(described_class.sql_type(:integer)).to match(:integer)
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'returns the sql type for :datetime' do
|
72
|
+
expect(described_class.sql_type(:datetime)).to eq(:datetime)
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'raises UnknownSqlType' do
|
76
|
+
expect do
|
77
|
+
described_class.sql_type(:email)
|
78
|
+
end.to raise_exception(::DeclareSchema::UnknownSqlTypeError, /:email for type :email/)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
describe '.deserialize_default_value' do
|
83
|
+
require 'rails'
|
84
|
+
|
85
|
+
if ::Rails::VERSION::MAJOR >= 5
|
86
|
+
it 'deserializes :boolean' do
|
87
|
+
expect(described_class.deserialize_default_value(nil, :boolean, 'true')).to eq(true)
|
88
|
+
expect(described_class.deserialize_default_value(nil, :boolean, 'false')).to eq(false)
|
89
|
+
end
|
90
|
+
|
91
|
+
it 'deserializes :integer' do
|
92
|
+
expect(described_class.deserialize_default_value(nil, :integer, '12')).to eq(12)
|
93
|
+
end
|
94
|
+
|
95
|
+
it 'deserializes :json' do
|
96
|
+
expect(described_class.deserialize_default_value(nil, :json, '{}')).to eq({})
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
describe 'instance methods' do
|
103
|
+
before do
|
104
|
+
class ColumnTestModel < ActiveRecord::Base
|
105
|
+
fields do
|
106
|
+
title :string, limit: 127, null: false
|
107
|
+
count :integer, null: false
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
let(:model) { ColumnTestModel }
|
112
|
+
let(:current_table_name) { model.table_name }
|
113
|
+
let(:column) { double("ActiveRecord Column",
|
114
|
+
name: 'count',
|
115
|
+
type: :integer,
|
116
|
+
limit: nil,
|
117
|
+
precision: nil,
|
118
|
+
scale: nil,
|
119
|
+
type_cast_from_database: nil,
|
120
|
+
null: false,
|
121
|
+
default: nil,
|
122
|
+
sql_type_metadata: {}) }
|
123
|
+
subject { described_class.new(model, current_table_name, column) }
|
124
|
+
|
125
|
+
describe '#sql_type' do
|
126
|
+
it 'returns sql type' do
|
127
|
+
expect(subject.sql_type).to match(/int/)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
describe '#schema_attributes' do
|
132
|
+
it 'returns a hash with relevant key/values' do
|
133
|
+
if defined?(Mysql2)
|
134
|
+
expect(subject.schema_attributes).to eq(type: :integer, null: false, limit: 4)
|
135
|
+
else
|
136
|
+
expect(subject.schema_attributes).to eq(type: :integer, null: false)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../../../lib/declare_schema/model/foreign_key_definition'
|
4
|
+
|
5
|
+
RSpec.describe DeclareSchema::Model::ForeignKeyDefinition do
|
6
|
+
before do
|
7
|
+
load File.expand_path('../prepare_testapp.rb', __dir__)
|
8
|
+
|
9
|
+
class Network < ActiveRecord::Base
|
10
|
+
fields do
|
11
|
+
name :string, limit: 127, index: true
|
12
|
+
|
13
|
+
timestamps
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
let(:model_class) { Network }
|
19
|
+
|
20
|
+
describe 'instance methods' do
|
21
|
+
let(:connection) { instance_double(ActiveRecord::Base.connection.class) }
|
22
|
+
let(:model) { instance_double('Model', table_name: 'models', connection: connection) }
|
23
|
+
let(:foreign_key) { :network_id }
|
24
|
+
let(:options) { {} }
|
25
|
+
subject { described_class.new(model, foreign_key, options)}
|
26
|
+
|
27
|
+
before do
|
28
|
+
allow(connection).to receive(:index_name).with('models', column: 'network_id') { 'on_network_id' }
|
29
|
+
end
|
30
|
+
|
31
|
+
describe '#initialize' do
|
32
|
+
it 'normalizes symbols to strings' do
|
33
|
+
expect(subject.foreign_key).to eq('network_id')
|
34
|
+
expect(subject.parent_table_name).to eq('networks')
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'when most options passed' do
|
38
|
+
let(:options) { { parent_table: :networks, foreign_key: :the_network_id, index_name: :index_on_network_id } }
|
39
|
+
|
40
|
+
it 'normalizes symbols to strings' do
|
41
|
+
expect(subject.foreign_key).to eq('network_id')
|
42
|
+
expect(subject.foreign_key_name).to eq('the_network_id')
|
43
|
+
expect(subject.parent_table_name).to eq('networks')
|
44
|
+
expect(subject.foreign_key).to eq('network_id')
|
45
|
+
expect(subject.constraint_name).to eq('index_on_network_id')
|
46
|
+
expect(subject.on_delete_cascade).to be_falsey
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context 'when all options passed' do
|
51
|
+
let(:foreign_key) { nil }
|
52
|
+
let(:options) { { parent_table: :networks, foreign_key: :the_network_id, index_name: :index_on_network_id,
|
53
|
+
constraint_name: :constraint_1, dependent: :delete } }
|
54
|
+
|
55
|
+
it 'normalizes symbols to strings' do
|
56
|
+
expect(subject.foreign_key).to be_nil
|
57
|
+
expect(subject.foreign_key_name).to eq('the_network_id')
|
58
|
+
expect(subject.parent_table_name).to eq('networks')
|
59
|
+
expect(subject.constraint_name).to eq('constraint_1')
|
60
|
+
expect(subject.on_delete_cascade).to be_truthy
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
describe '#to_add_statement' do
|
66
|
+
it 'returns add_foreign_key command' do
|
67
|
+
expect(subject.to_add_statement).to eq('add_foreign_key("models", "networks", column: "network_id", name: "on_network_id")')
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe 'class << self' do
|
73
|
+
let(:connection) { instance_double(ActiveRecord::Base.connection.class) }
|
74
|
+
let(:model) { instance_double('Model', table_name: 'models', connection: connection) }
|
75
|
+
let(:old_table_name) { 'networks' }
|
76
|
+
before do
|
77
|
+
allow(connection).to receive(:quote_table_name).with('networks') { 'networks' }
|
78
|
+
allow(connection).to receive(:select_rows) { [['CONSTRAINT `constraint` FOREIGN KEY (`network_id`) REFERENCES `networks` (`id`)']] }
|
79
|
+
allow(connection).to receive(:index_name).with('models', column: 'network_id') { }
|
80
|
+
end
|
81
|
+
|
82
|
+
describe '.for_model' do
|
83
|
+
subject { described_class.for_model(model, old_table_name) }
|
84
|
+
|
85
|
+
it 'returns new object' do
|
86
|
+
expect(subject.size).to eq(1), subject.inspect
|
87
|
+
expect(subject.first).to be_kind_of(described_class)
|
88
|
+
expect(subject.first.foreign_key).to eq('network_id')
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
# TODO: fill out remaining tests
|
93
|
+
end
|
@@ -64,7 +64,7 @@ RSpec.describe DeclareSchema::Model::IndexDefinition do
|
|
64
64
|
ActiveRecord::Base.connection.execute <<~EOS
|
65
65
|
CREATE TABLE index_definition_test_models (
|
66
66
|
id INTEGER NOT NULL PRIMARY KEY,
|
67
|
-
name TEXT NOT NULL
|
67
|
+
name #{if defined?(Sqlite3) then 'TEXT' else 'VARCHAR(255)' end} NOT NULL
|
68
68
|
)
|
69
69
|
EOS
|
70
70
|
ActiveRecord::Base.connection.execute <<~EOS
|
@@ -72,8 +72,8 @@ RSpec.describe DeclareSchema::Model::IndexDefinition do
|
|
72
72
|
EOS
|
73
73
|
ActiveRecord::Base.connection.execute <<~EOS
|
74
74
|
CREATE TABLE index_definition_compound_index_models (
|
75
|
-
fk1_id INTEGER NULL,
|
76
|
-
fk2_id INTEGER NULL,
|
75
|
+
fk1_id INTEGER NOT NULL,
|
76
|
+
fk2_id INTEGER NOT NULL,
|
77
77
|
PRIMARY KEY (fk1_id, fk2_id)
|
78
78
|
)
|
79
79
|
EOS
|
@@ -99,10 +99,9 @@ RSpec.describe DeclareSchema::Model::IndexDefinition do
|
|
99
99
|
let(:model_class) { IndexDefinitionCompoundIndexModel }
|
100
100
|
|
101
101
|
it 'returns the indexes for the model' do
|
102
|
-
# Simulate MySQL for Rails 4 work-around
|
103
102
|
if Rails::VERSION::MAJOR < 5
|
104
103
|
expect(model_class.connection).to receive(:primary_key).with('index_definition_compound_index_models').and_return(nil)
|
105
|
-
connection_stub = instance_double(ActiveRecord::
|
104
|
+
connection_stub = instance_double(ActiveRecord::Base.connection.class, "connection")
|
106
105
|
expect(connection_stub).to receive(:indexes).
|
107
106
|
with('index_definition_compound_index_models').
|
108
107
|
and_return([DeclareSchema::Model::IndexDefinition.new(model_class, ['fk1_id', 'fk2_id'], name: 'PRIMARY')])
|
@@ -1,6 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
begin
|
4
|
+
require 'mysql2'
|
5
|
+
require 'active_record/connection_adapters/mysql2_adapter'
|
6
|
+
rescue LoadError
|
7
|
+
end
|
4
8
|
require_relative '../../../../lib/declare_schema/model/table_options_definition'
|
5
9
|
|
6
10
|
RSpec.describe DeclareSchema::Model::TableOptionsDefinition do
|
@@ -22,7 +26,7 @@ RSpec.describe DeclareSchema::Model::TableOptionsDefinition do
|
|
22
26
|
|
23
27
|
describe '#to_key' do
|
24
28
|
subject { model.to_key }
|
25
|
-
it { should eq([
|
29
|
+
it { should eq(['table_options_definition_test_models', '{:charset=>"utf8", :collation=>"utf8_general"}']) }
|
26
30
|
end
|
27
31
|
|
28
32
|
describe '#settings' do
|
@@ -32,7 +36,7 @@ RSpec.describe DeclareSchema::Model::TableOptionsDefinition do
|
|
32
36
|
|
33
37
|
describe '#hash' do
|
34
38
|
subject { model.hash }
|
35
|
-
it { should eq([
|
39
|
+
it { should eq(['table_options_definition_test_models', '{:charset=>"utf8", :collation=>"utf8_general"}'].hash) }
|
36
40
|
end
|
37
41
|
|
38
42
|
describe '#to_s' do
|
@@ -42,42 +46,28 @@ RSpec.describe DeclareSchema::Model::TableOptionsDefinition do
|
|
42
46
|
|
43
47
|
describe '#alter_table_statement' do
|
44
48
|
subject { model.alter_table_statement }
|
45
|
-
it { should
|
49
|
+
it { should match(/execute "ALTER TABLE .*table_options_definition_test_models.* CHARACTER SET utf8 COLLATE utf8_general"/) }
|
46
50
|
end
|
47
51
|
end
|
48
52
|
|
49
53
|
|
50
54
|
context 'class << self' do
|
51
55
|
describe '#for_model' do
|
52
|
-
context 'when
|
56
|
+
context 'when database migrated' do
|
57
|
+
let(:options) do
|
58
|
+
if defined?(Mysql2)
|
59
|
+
{ charset: "utf8mb4", collation: "utf8mb4_bin" }
|
60
|
+
else
|
61
|
+
{ }
|
62
|
+
end
|
63
|
+
end
|
53
64
|
subject { described_class.for_model(model_class) }
|
54
|
-
|
55
|
-
end
|
56
|
-
# TODO: Convert these tests to run against a MySQL database so that we can
|
57
|
-
# perform them without mocking out so much
|
58
|
-
context 'when using a MySQL connection' do
|
65
|
+
|
59
66
|
before do
|
60
|
-
|
61
|
-
expect(stub_connection).to receive(:class).and_return(ActiveRecord::ConnectionAdapters::Mysql2Adapter)
|
62
|
-
expect(stub_connection).to receive(:current_database).and_return('test_database')
|
63
|
-
expect(stub_connection).to receive(:quote_string).with('test_database').and_return('test_database')
|
64
|
-
expect(stub_connection).to receive(:quote_string).with(model_class.table_name).and_return(model_class.table_name)
|
65
|
-
expect(stub_connection).to(
|
66
|
-
receive(:select_one).with(<<~EOS)
|
67
|
-
SELECT CCSA.character_set_name, CCSA.collation_name
|
68
|
-
FROM information_schema.`TABLES` T, information_schema.`COLLATION_CHARACTER_SET_APPLICABILITY` CCSA
|
69
|
-
WHERE CCSA.collation_name = T.table_collation AND
|
70
|
-
T.table_schema = 'test_database' AND
|
71
|
-
T.table_name = '#{model_class.table_name}';
|
72
|
-
EOS
|
73
|
-
.and_return({ "character_set_name" => "utf8", "collation_name" => "utf8_general" })
|
74
|
-
)
|
75
|
-
allow(model_class).to receive(:connection).and_return(stub_connection)
|
76
|
-
end
|
67
|
+
generate_migrations '-n', '-m'
|
77
68
|
end
|
78
69
|
|
79
|
-
|
80
|
-
it { should eq(described_class.new(model_class.table_name, { charset: "utf8", collation: "utf8_general" })) }
|
70
|
+
it { should eq(described_class.new(model_class.table_name, options)) }
|
81
71
|
end
|
82
72
|
end
|
83
73
|
end
|
@@ -1,5 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
begin
|
4
|
+
require 'mysql2'
|
5
|
+
rescue LoadError
|
6
|
+
end
|
3
7
|
require 'rails'
|
4
8
|
require 'rails/generators'
|
5
9
|
|
@@ -14,26 +18,8 @@ module Generators
|
|
14
18
|
subject { described_class.new }
|
15
19
|
|
16
20
|
describe 'format_options' do
|
17
|
-
|
18
|
-
|
19
|
-
context 'MySQL' do
|
20
|
-
before do
|
21
|
-
expect(::DeclareSchema::Model::FieldSpec).to receive(:mysql_text_limits?).and_return(true)
|
22
|
-
end
|
23
|
-
|
24
|
-
it 'returns text limits' do
|
25
|
-
expect(subject.format_options({ limit: mysql_longtext_limit }, :text)).to eq(["limit: #{mysql_longtext_limit}"])
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
context 'non-MySQL' do
|
30
|
-
before do
|
31
|
-
expect(::DeclareSchema::Model::FieldSpec).to receive(:mysql_text_limits?).and_return(false)
|
32
|
-
end
|
33
|
-
|
34
|
-
it 'returns text limits' do
|
35
|
-
expect(subject.format_options({ limit: mysql_longtext_limit }, :text)).to eq([])
|
36
|
-
end
|
21
|
+
it 'returns an array of option .inspect strings, with symbols using the modern : hash notation' do
|
22
|
+
expect(subject.format_options({ limit: 4, 'key' => 'value "quoted"' })).to eq(["limit: 4", '"key" => "value \"quoted\""'])
|
37
23
|
end
|
38
24
|
end
|
39
25
|
|
@@ -47,13 +33,13 @@ module Generators
|
|
47
33
|
subject { described_class.default_charset }
|
48
34
|
|
49
35
|
context 'when not explicitly set' do
|
50
|
-
it { should eq(
|
36
|
+
it { should eq("utf8mb4") }
|
51
37
|
end
|
52
38
|
|
53
39
|
context 'when explicitly set' do
|
54
|
-
before { described_class.default_charset =
|
40
|
+
before { described_class.default_charset = "utf8" }
|
55
41
|
after { described_class.default_charset = described_class::DEFAULT_CHARSET }
|
56
|
-
it { should eq(
|
42
|
+
it { should eq("utf8") }
|
57
43
|
end
|
58
44
|
end
|
59
45
|
|
@@ -61,13 +47,13 @@ module Generators
|
|
61
47
|
subject { described_class.default_collation }
|
62
48
|
|
63
49
|
context 'when not explicitly set' do
|
64
|
-
it { should eq(
|
50
|
+
it { should eq("utf8mb4_bin") }
|
65
51
|
end
|
66
52
|
|
67
53
|
context 'when explicitly set' do
|
68
|
-
before { described_class.default_collation =
|
54
|
+
before { described_class.default_collation = "utf8mb4_general_ci" }
|
69
55
|
after { described_class.default_collation = described_class::DEFAULT_COLLATION }
|
70
|
-
it { should eq(
|
56
|
+
it { should eq("utf8mb4_general_ci") }
|
71
57
|
end
|
72
58
|
end
|
73
59
|
|
@@ -23,7 +23,7 @@ module AcceptanceSpecHelpers
|
|
23
23
|
|
24
24
|
def expect_file_to_eq(file_path, expectation)
|
25
25
|
expect(File.exist?(file_path)).to be_truthy
|
26
|
-
expect(File.read(file_path)).to eq(expectation)
|
26
|
+
expect(File.read(file_path).gsub(/require '([^']*)'/, 'require "\1"')).to eq(expectation)
|
27
27
|
end
|
28
28
|
|
29
29
|
def clean_up_model(model)
|
@@ -45,13 +45,13 @@ module AcceptanceSpecHelpers
|
|
45
45
|
|
46
46
|
class MigrationUpEquals < RSpec::Matchers::BuiltIn::Eq
|
47
47
|
def matches?(subject)
|
48
|
-
super(subject[0])
|
48
|
+
super(subject[0].gsub(/, +([a-z_]+:)/i, ', \1')) # normalize multiple spaces to one
|
49
49
|
end
|
50
50
|
end
|
51
51
|
|
52
52
|
class MigrationDownEquals < RSpec::Matchers::BuiltIn::Eq
|
53
53
|
def matches?(subject)
|
54
|
-
super(subject[1])
|
54
|
+
super(subject[1].gsub(/, +([a-z_]+:)/i, ', \1')) # normalize multiple spaces to one
|
55
55
|
end
|
56
56
|
end
|
57
57
|
end
|