hobofields 0.7.5
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.
- data/CHANGES.txt +38 -0
- data/LICENSE.txt +22 -0
- data/Manifest +25 -0
- data/README.txt +8 -0
- data/generators/hobo_migration/hobo_migration_generator.rb +95 -0
- data/generators/hobo_migration/templates/migration.rb +9 -0
- data/hobofields.gemspec +42 -0
- data/init.rb +4 -0
- data/lib/hobo_fields/email_address.rb +24 -0
- data/lib/hobo_fields/enum_string.rb +64 -0
- data/lib/hobo_fields/field_declaration_dsl.rb +29 -0
- data/lib/hobo_fields/field_spec.rb +72 -0
- data/lib/hobo_fields/fields_declaration.rb +22 -0
- data/lib/hobo_fields/html_string.rb +15 -0
- data/lib/hobo_fields/markdown_string.rb +13 -0
- data/lib/hobo_fields/migration_generator.rb +293 -0
- data/lib/hobo_fields/model_extensions.rb +172 -0
- data/lib/hobo_fields/password_string.rb +15 -0
- data/lib/hobo_fields/text.rb +17 -0
- data/lib/hobo_fields/textile_string.rb +29 -0
- data/lib/hobo_fields.rb +139 -0
- data/lib/hobofields.rb +1 -0
- data/test/hobofields.rdoctest +57 -0
- data/test/hobofields_api.rdoctest +247 -0
- data/test/migration_generator.rdoctest +410 -0
- data/test/rich_types.rdoctest +215 -0
- metadata +98 -0
@@ -0,0 +1,293 @@
|
|
1
|
+
module HoboFields
|
2
|
+
|
3
|
+
class MigrationGeneratorError < RuntimeError; end
|
4
|
+
|
5
|
+
class MigrationGenerator
|
6
|
+
|
7
|
+
@ignore_models = []
|
8
|
+
@ignore_tables = []
|
9
|
+
|
10
|
+
class << self
|
11
|
+
attr_accessor :ignore_models, :ignore_tables
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.run(renames={})
|
15
|
+
g = MigrationGenerator.new
|
16
|
+
g.renames = renames
|
17
|
+
g.generate
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(ambiguity_resolver=nil)
|
21
|
+
@ambiguity_resolver = ambiguity_resolver
|
22
|
+
@drops = []
|
23
|
+
@renames = nil
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_accessor :renames
|
27
|
+
|
28
|
+
def load_rails_models
|
29
|
+
if defined? RAILS_ROOT
|
30
|
+
Dir.entries("#{RAILS_ROOT}/app/models/").each do |f|
|
31
|
+
f =~ /^[a-zA-Z_][a-zA-Z0-9_]*\.rb$/ and f.sub(/.rb$/, '').camelize.constantize
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
# Returns an array of model classes that *directly* extend
|
38
|
+
# ActiveRecord::Base, excluding anything in the CGI module
|
39
|
+
def table_model_classes
|
40
|
+
load_rails_models
|
41
|
+
ActiveRecord::Base.send(:subclasses).where.descends_from_active_record?.reject {|c| c.name.starts_with?("CGI::") }
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
def connection
|
46
|
+
ActiveRecord::Base.connection
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
def native_types
|
51
|
+
connection.native_database_types
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
# Returns an array of model classes and an array of table names
|
56
|
+
# that generation needs to take into account
|
57
|
+
def models_and_tables
|
58
|
+
ignore_model_names = MigrationGenerator.ignore_models.map &it.to_s.underscore
|
59
|
+
models = table_model_classes.select { |m| m < HoboFields::ModelExtensions && m.name.underscore.not_in?(ignore_model_names) }
|
60
|
+
db_tables = connection.tables - MigrationGenerator.ignore_tables.*.to_s
|
61
|
+
[models, db_tables]
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
# return a hash of table renames and modifies the passed arrays so
|
66
|
+
# that renamed tables are no longer listed as to_create or to_drop
|
67
|
+
def extract_table_renames!(to_create, to_drop)
|
68
|
+
if renames
|
69
|
+
# A hash of table renames has been provided
|
70
|
+
|
71
|
+
to_rename = {}
|
72
|
+
renames.each_pair do |old_name, new_name|
|
73
|
+
new_name = new_name[:table_name] if new_name.is_a?(Hash)
|
74
|
+
next unless new_name
|
75
|
+
|
76
|
+
if to_create.delete(new_name.to_s) && to_drop.delete(old_name.to_s)
|
77
|
+
to_rename[old_name.to_s] = new_name.to_s
|
78
|
+
else
|
79
|
+
raise MigrationGeneratorError, "Invalid table rename specified: #{old_name} => #{new_name}"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
to_rename
|
83
|
+
|
84
|
+
elsif @ambiguity_resolver
|
85
|
+
@ambiguity_resolver.extract_renames!(to_create, to_drop, "table")
|
86
|
+
|
87
|
+
else
|
88
|
+
raise MigrationGeneratorError, "Unable to resolve migration ambiguities"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
def extract_column_renames!(to_add, to_remove, table_name)
|
94
|
+
if renames
|
95
|
+
to_rename = {}
|
96
|
+
column_renames = renames._?[table_name.to_sym]
|
97
|
+
if column_renames
|
98
|
+
# A hash of table renames has been provided
|
99
|
+
|
100
|
+
column_renames.each_pair do |old_name, new_name|
|
101
|
+
if to_add.delete(new_name.to_s) && to_remove.delete(old_name.to_s)
|
102
|
+
to_rename[old_name.to_s] = new_name.to_s
|
103
|
+
else
|
104
|
+
raise MigrationGeneratorError, "Invalid rename specified: #{old_name} => #{new_name}"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
to_rename
|
109
|
+
|
110
|
+
elsif @ambiguity_resolver
|
111
|
+
@ambiguity_resolver.extract_renames!(to_add, to_remove, "column", "#{table_name}.")
|
112
|
+
|
113
|
+
else
|
114
|
+
raise MigrationGeneratorError, "Unable to resolve migration ambiguities in table #{table_name}"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
def generate
|
120
|
+
models, db_tables = models_and_tables
|
121
|
+
models_by_table_name = models.index_by {|m| m.table_name}
|
122
|
+
model_table_names = models.*.table_name
|
123
|
+
|
124
|
+
to_create = model_table_names - db_tables
|
125
|
+
to_drop = db_tables - model_table_names - ['schema_info']
|
126
|
+
to_change = model_table_names
|
127
|
+
|
128
|
+
to_rename = extract_table_renames!(to_create, to_drop)
|
129
|
+
|
130
|
+
renames = to_rename.map do |old_name, new_name|
|
131
|
+
"rename_table :#{old_name}, :#{new_name}"
|
132
|
+
end * "\n"
|
133
|
+
undo_renames = to_rename.map do |old_name, new_name|
|
134
|
+
"rename_table :#{new_name}, :#{old_name}"
|
135
|
+
end * "\n"
|
136
|
+
|
137
|
+
drops = to_drop.map do |t|
|
138
|
+
"drop_table :#{t}"
|
139
|
+
end * "\n"
|
140
|
+
undo_drops = to_drop.map do |t|
|
141
|
+
revert_table(t)
|
142
|
+
end * "\n\n"
|
143
|
+
|
144
|
+
creates = to_create.map do |t|
|
145
|
+
create_table(models_by_table_name[t])
|
146
|
+
end * "\n\n"
|
147
|
+
undo_creates = to_create.map do |t|
|
148
|
+
"drop_table :#{t}"
|
149
|
+
end * "\n"
|
150
|
+
|
151
|
+
changes = []
|
152
|
+
undo_changes = []
|
153
|
+
to_change.each do |t|
|
154
|
+
model = models_by_table_name[t]
|
155
|
+
table = to_rename.index(t) || model.table_name
|
156
|
+
if table.in?(db_tables)
|
157
|
+
change, undo = change_table(model, table)
|
158
|
+
changes << change
|
159
|
+
undo_changes << undo
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
up = [renames, drops, creates, changes].flatten.reject(&:blank?) * "\n\n"
|
164
|
+
down = [undo_changes, undo_renames, undo_drops, undo_creates].flatten.reject(&:blank?) * "\n\n"
|
165
|
+
|
166
|
+
[up, down]
|
167
|
+
end
|
168
|
+
|
169
|
+
def create_table(model)
|
170
|
+
longest_field_name = model.field_specs.values.map { |f| f.sql_type.to_s.length }.max
|
171
|
+
(["create_table :#{model.table_name} do |t|"] +
|
172
|
+
model.field_specs.values.sort_by{|f| f.position}.map {|f| create_field(f, longest_field_name)} +
|
173
|
+
["end"]) * "\n"
|
174
|
+
end
|
175
|
+
|
176
|
+
def create_field(field_spec, field_name_width)
|
177
|
+
args = [field_spec.name.inspect] + format_options(field_spec.options, field_spec.sql_type)
|
178
|
+
" t.%-*s %s" % [field_name_width, field_spec.sql_type, args.join(', ')]
|
179
|
+
end
|
180
|
+
|
181
|
+
def change_table(model, current_table_name)
|
182
|
+
new_table_name = model.table_name
|
183
|
+
|
184
|
+
db_columns = model.connection.columns(current_table_name).index_by{|c|c.name} - [model.primary_key]
|
185
|
+
model_column_names = model.field_specs.keys.*.to_s
|
186
|
+
db_column_names = db_columns.keys.*.to_s
|
187
|
+
|
188
|
+
to_add = model_column_names - db_column_names
|
189
|
+
to_remove = db_column_names - model_column_names - [model.primary_key.to_sym]
|
190
|
+
|
191
|
+
to_rename = extract_column_renames!(to_add, to_remove, new_table_name)
|
192
|
+
|
193
|
+
db_column_names -= to_rename.keys
|
194
|
+
db_column_names |= to_rename.values
|
195
|
+
to_change = db_column_names & model_column_names
|
196
|
+
|
197
|
+
renames = to_rename.map do |old_name, new_name|
|
198
|
+
"rename_column :#{new_table_name}, :#{old_name}, :#{new_name}"
|
199
|
+
end
|
200
|
+
undo_renames = to_rename.map do |old_name, new_name|
|
201
|
+
"rename_column :#{new_table_name}, :#{new_name}, :#{old_name}"
|
202
|
+
end
|
203
|
+
|
204
|
+
to_add = to_add.sort_by {|c| model.field_specs[c].position }
|
205
|
+
adds = to_add.map do |c|
|
206
|
+
spec = model.field_specs[c]
|
207
|
+
args = [":#{spec.sql_type}"] + format_options(spec.options, spec.sql_type)
|
208
|
+
"add_column :#{new_table_name}, :#{c}, #{args * ', '}"
|
209
|
+
end
|
210
|
+
undo_adds = to_add.map do |c|
|
211
|
+
"remove_column :#{new_table_name}, :#{c}"
|
212
|
+
end
|
213
|
+
|
214
|
+
removes = to_remove.map do |c|
|
215
|
+
"remove_column :#{new_table_name}, :#{c}"
|
216
|
+
end
|
217
|
+
undo_removes = to_remove.map do |c|
|
218
|
+
revert_column(current_table_name, c)
|
219
|
+
end
|
220
|
+
|
221
|
+
old_names = to_rename.invert
|
222
|
+
changes = []
|
223
|
+
undo_changes = []
|
224
|
+
to_change.each do |c|
|
225
|
+
col_name = old_names[c] || c
|
226
|
+
col = db_columns[col_name]
|
227
|
+
spec = model.field_specs[c]
|
228
|
+
if spec.different_to?(col)
|
229
|
+
change_spec = {}
|
230
|
+
change_spec[:limit] = spec.limit unless spec.limit.nil?
|
231
|
+
change_spec[:precision] = spec.precision unless spec.precision.nil?
|
232
|
+
change_spec[:scale] = spec.scale unless spec.scale.nil?
|
233
|
+
change_spec[:null] = false unless spec.null
|
234
|
+
change_spec[:default] = spec.default unless spec.default.nil? && col.default.nil?
|
235
|
+
|
236
|
+
changes << "change_column :#{new_table_name}, :#{c}, " +
|
237
|
+
([":#{spec.sql_type}"] + format_options(change_spec, spec.sql_type)).join(", ")
|
238
|
+
back = change_column_back(current_table_name, col_name)
|
239
|
+
undo_changes << back unless back.blank?
|
240
|
+
else
|
241
|
+
nil
|
242
|
+
end
|
243
|
+
end.compact
|
244
|
+
|
245
|
+
[(renames + adds + removes + changes) * "\n",
|
246
|
+
(undo_renames + undo_adds + undo_removes + undo_changes) * "\n"]
|
247
|
+
end
|
248
|
+
|
249
|
+
|
250
|
+
def format_options(options, type)
|
251
|
+
options.map do |k, v|
|
252
|
+
next if k == :limit && (type == :decimal || v == native_types[type][:limit])
|
253
|
+
next if k == :null && v == true
|
254
|
+
"#{k.inspect} => #{v.inspect}"
|
255
|
+
end.compact
|
256
|
+
end
|
257
|
+
|
258
|
+
|
259
|
+
def revert_table(table)
|
260
|
+
res = StringIO.new
|
261
|
+
ActiveRecord::SchemaDumper.send(:new, ActiveRecord::Base.connection).send(:table, table, res)
|
262
|
+
res.string.strip.gsub("\n ", "\n")
|
263
|
+
end
|
264
|
+
|
265
|
+
|
266
|
+
def column_options_from_reverted_table(table, column)
|
267
|
+
revert = revert_table(table)
|
268
|
+
if (md = revert.match(/\s*t\.column\s+"#{column}",\s+(:[a-zA-Z0-9_]+)(?:,\s+(.*?)$)?/m))
|
269
|
+
# Ugly migration
|
270
|
+
_, type, options = *md
|
271
|
+
elsif (md = revert.match(/\s*t\.([a-z_]+)\s+"#{column}"(?:,\s+(.*?)$)?/m))
|
272
|
+
# Sexy migration
|
273
|
+
_, type, options = *md
|
274
|
+
type = ":#{type}"
|
275
|
+
end
|
276
|
+
[type, options]
|
277
|
+
end
|
278
|
+
|
279
|
+
|
280
|
+
def change_column_back(table, column)
|
281
|
+
type, options = column_options_from_reverted_table(table, column)
|
282
|
+
"change_column :#{table}, :#{column}, #{type}#{', ' + options.strip if options}"
|
283
|
+
end
|
284
|
+
|
285
|
+
|
286
|
+
def revert_column(table, column)
|
287
|
+
type, options = column_options_from_reverted_table(table, column)
|
288
|
+
"add_column :#{table}, :#{column}, #{type}#{', ' + options.strip if options}"
|
289
|
+
end
|
290
|
+
|
291
|
+
end
|
292
|
+
|
293
|
+
end
|
@@ -0,0 +1,172 @@
|
|
1
|
+
module HoboFields
|
2
|
+
|
3
|
+
ModelExtensions = classy_module do
|
4
|
+
|
5
|
+
|
6
|
+
# attr_types holds the type class for any attribute reader (i.e. getter
|
7
|
+
# method) that returns rich-types
|
8
|
+
inheriting_cattr_reader :attr_types => HashWithIndifferentAccess.new
|
9
|
+
|
10
|
+
# field_specs holds FieldSpec objects for every declared
|
11
|
+
# field. Note that attribute readers are created (by ActiveRecord)
|
12
|
+
# for all fields, so there is also an entry for the field in
|
13
|
+
# attr_types. This is redundant but simplifies the implementation
|
14
|
+
# and speeds things up a little.
|
15
|
+
inheriting_cattr_reader :field_specs => HashWithIndifferentAccess.new
|
16
|
+
|
17
|
+
|
18
|
+
def self.inherited(klass)
|
19
|
+
fields do |f|
|
20
|
+
f.field(inheritance_column, :string)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
def self.field_specs
|
26
|
+
@field_specs ||= HashWithIndifferentAccess.new
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Declares that a virtual field that has a rich type (e.g. created
|
33
|
+
# by attr_accessor :foo, :type => :email_address) should be subject
|
34
|
+
# to validation (note that the rich types know how to validate themselves)
|
35
|
+
def self.validate_virtual_field(*args)
|
36
|
+
validates_each(*args) {|record, field, value| msg = value.validate and record.errors.add(field, msg) if value.respond_to?(:validate) }
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
# This adds a ":type => t" option to attr_accessor, where t is
|
41
|
+
# either a class or a symbolic name of a rich type. If this option
|
42
|
+
# is given, the setter will wrap values that are not of the right
|
43
|
+
# type.
|
44
|
+
def self.attr_accessor_with_rich_types(*attrs)
|
45
|
+
options = attrs.extract_options!
|
46
|
+
type = options.delete(:type)
|
47
|
+
attrs << options unless options.empty?
|
48
|
+
attr_accessor_without_rich_types(*attrs)
|
49
|
+
|
50
|
+
if type
|
51
|
+
type = HoboFields.to_class(type)
|
52
|
+
attrs.each do |attr|
|
53
|
+
declare_attr_type attr, type
|
54
|
+
define_method "#{attr}=" do |val|
|
55
|
+
if !val.is_a?(type) && HoboFields.can_wrap?(val)
|
56
|
+
val = type.new(val.to_s)
|
57
|
+
end
|
58
|
+
instance_variable_set("@#{attr}", val)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
# Extend belongs_to so that it creates a FieldSpec for the foreign key
|
66
|
+
def self.belongs_to_with_field_declarations(name, options={}, &block)
|
67
|
+
res = belongs_to_without_field_declarations(name, options, &block)
|
68
|
+
refl = reflections[name.to_sym]
|
69
|
+
fkey = refl.primary_key_name
|
70
|
+
column_options = {}
|
71
|
+
column_options[:null] = options[:null] if options.has_key?(:null)
|
72
|
+
declare_field(fkey, :integer, column_options)
|
73
|
+
declare_polymorphic_type_field(name, column_options) if refl.options[:polymorphic]
|
74
|
+
res
|
75
|
+
end
|
76
|
+
class << self
|
77
|
+
alias_method_chain :belongs_to, :field_declarations
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
# Declares the "foo_type" field that accompanies the "foo_id"
|
82
|
+
# field for a polyorphic belongs_to
|
83
|
+
def self.declare_polymorphic_type_field(name, column_options)
|
84
|
+
type_col = "#{name}_type"
|
85
|
+
declare_field(type_col, :string, column_options)
|
86
|
+
# FIXME: Before hobofields was extracted, this used to now do:
|
87
|
+
# never_show(type_col)
|
88
|
+
# That needs doing somewhere
|
89
|
+
end
|
90
|
+
|
91
|
+
|
92
|
+
# Declare a rich-type for any attribute (i.e. getter method). This
|
93
|
+
# does not effect the attribute in any way - it just records the
|
94
|
+
# metadata.
|
95
|
+
def self.declare_attr_type(name, type)
|
96
|
+
attr_types[name] = HoboFields.to_class(type)
|
97
|
+
end
|
98
|
+
|
99
|
+
|
100
|
+
# Declare named field with a type and an arbitrary set of
|
101
|
+
# arguments. The arguments are forwarded to the #field_added
|
102
|
+
# callback, allowing custom metadata to be added to field
|
103
|
+
# declarations.
|
104
|
+
def self.declare_field(name, type, *args)
|
105
|
+
options = args.extract_options!
|
106
|
+
try.field_added(name, type, args, options)
|
107
|
+
add_validations_for_field(name, type, args, options)
|
108
|
+
declare_attr_type(name, type) unless HoboFields.plain_type?(type)
|
109
|
+
field_specs[name] = FieldSpec.new(self, name, type, options)
|
110
|
+
end
|
111
|
+
|
112
|
+
|
113
|
+
# Add field validations according to arguments and options in the
|
114
|
+
# field declaration
|
115
|
+
def self.add_validations_for_field(name, type, args, options)
|
116
|
+
validates_presence_of name if :required.in?(args)
|
117
|
+
validates_uniqueness_of name if :unique.in?(args)
|
118
|
+
|
119
|
+
type_class = HoboFields.to_class(type)
|
120
|
+
if type_class && "validate".in?(type_class.public_instance_methods)
|
121
|
+
self.validate do |record|
|
122
|
+
v = record.send(name)._?.validate
|
123
|
+
record.errors.add(name, v) if v.is_a?(String)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
|
129
|
+
# Extended version of the acts_as_list declaration that
|
130
|
+
# automatically delcares the 'position' field
|
131
|
+
def self.acts_as_list_with_field_declaration(options = {})
|
132
|
+
declare_field(options.fetch(:column, "position"), :integer)
|
133
|
+
acts_as_list_without_field_declaration(options)
|
134
|
+
end
|
135
|
+
|
136
|
+
|
137
|
+
# Returns the type (a class) for a given field or association. If
|
138
|
+
# the association is a collection (has_many or habtm) return the
|
139
|
+
# AssociationReflection instead
|
140
|
+
def self.attr_type(name)
|
141
|
+
if attr_types.nil? && self != self.name.constantize
|
142
|
+
raise RuntimeError, "attr_types called on a stale class object (#{self.name}). Avoid storing persistent refereces to classes"
|
143
|
+
end
|
144
|
+
|
145
|
+
attr_types[name] or
|
146
|
+
|
147
|
+
if (refl = reflections[name.to_sym])
|
148
|
+
if refl.macro.in?([:has_one, :belongs_to])
|
149
|
+
refl.klass
|
150
|
+
else
|
151
|
+
refl
|
152
|
+
end
|
153
|
+
end or
|
154
|
+
|
155
|
+
(col = column(name.to_s) and HoboFields::PLAIN_TYPES[col.type] || col.klass)
|
156
|
+
end
|
157
|
+
|
158
|
+
|
159
|
+
# Return the entry from #columns for the named column
|
160
|
+
def self.column(name)
|
161
|
+
name = name.to_s
|
162
|
+
columns.find {|c| c.name == name }
|
163
|
+
end
|
164
|
+
|
165
|
+
class << self
|
166
|
+
alias_method_chain :acts_as_list, :field_declaration if defined?(ActiveRecord::Acts::List)
|
167
|
+
alias_method_chain :attr_accessor, :rich_types
|
168
|
+
end
|
169
|
+
|
170
|
+
end
|
171
|
+
|
172
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module HoboFields
|
2
|
+
|
3
|
+
class Text < String
|
4
|
+
|
5
|
+
HTML_ESCAPE = { '&' => '&', '"' => '"', '>' => '>', '<' => '<' }
|
6
|
+
|
7
|
+
COLUMN_TYPE = :text
|
8
|
+
|
9
|
+
def to_html
|
10
|
+
gsub(/[&"><]/) { |special| HTML_ESCAPE[special] }.gsub("\n", "<br />\n")
|
11
|
+
end
|
12
|
+
|
13
|
+
HoboFields.register_type(:text, self)
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'redcloth'
|
2
|
+
|
3
|
+
module HoboFields
|
4
|
+
|
5
|
+
class TextileString < HoboFields::Text
|
6
|
+
|
7
|
+
def to_html
|
8
|
+
if blank?
|
9
|
+
""
|
10
|
+
else
|
11
|
+
textilized = RedCloth.new(self, [ :hard_breaks ])
|
12
|
+
textilized.hard_breaks = true if textilized.respond_to?("hard_breaks=")
|
13
|
+
textilized.to_html
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
HoboFields.register_type(:textile, self)
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
class RedCloth
|
23
|
+
# Patch for RedCloth. Fixed in RedCloth r128 but _why hasn't released it yet.
|
24
|
+
# http://code.whytheluckystiff.net/redcloth/changeset/128
|
25
|
+
def hard_break( text )
|
26
|
+
text.gsub!( /(.)\n(?!\n|\Z| *([#*=]+(\s|$)|[{|]))/, "\\1<br />" ) if hard_breaks && RedCloth::VERSION == "3.0.4"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
data/lib/hobo_fields.rb
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
require 'hobosupport'
|
2
|
+
|
3
|
+
Dependencies.load_paths |= [ File.dirname(__FILE__) ] if defined?(Dependencies)
|
4
|
+
|
5
|
+
module Hobo
|
6
|
+
# Empty class to represent the boolean type.
|
7
|
+
class Boolean; end
|
8
|
+
end
|
9
|
+
|
10
|
+
module HoboFields
|
11
|
+
|
12
|
+
VERSION = "0.7.5"
|
13
|
+
|
14
|
+
extend self
|
15
|
+
|
16
|
+
PLAIN_TYPES = {
|
17
|
+
:boolean => Hobo::Boolean,
|
18
|
+
:date => Date,
|
19
|
+
:datetime => Time,
|
20
|
+
:integer => Fixnum,
|
21
|
+
:big_integer => BigDecimal,
|
22
|
+
:float => Float,
|
23
|
+
:string => String
|
24
|
+
}
|
25
|
+
|
26
|
+
# Provide a lookup for these rather than loading them all preemptively
|
27
|
+
STANDARD_TYPES = {
|
28
|
+
:html => "HtmlString",
|
29
|
+
:markdown => "MarkdownString",
|
30
|
+
:textile => "TextileString",
|
31
|
+
:password => "PasswordString",
|
32
|
+
:text => "Text",
|
33
|
+
:email_address => "EmailAddress"
|
34
|
+
}
|
35
|
+
|
36
|
+
@field_types = HashWithIndifferentAccess.new(PLAIN_TYPES)
|
37
|
+
@never_wrap_types = Set.new([NilClass, Hobo::Boolean, TrueClass, FalseClass])
|
38
|
+
|
39
|
+
attr_reader :field_types
|
40
|
+
|
41
|
+
def to_class(type)
|
42
|
+
if type.is_a?(Symbol, String)
|
43
|
+
type = type.to_sym
|
44
|
+
field_types[type] || standard_class(type)
|
45
|
+
else
|
46
|
+
type # assume it's already a class
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
|
51
|
+
def to_name(type)
|
52
|
+
field_types.index(type)
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
def can_wrap?(val)
|
57
|
+
# Make sure we get the *real* class
|
58
|
+
klass = Object.instance_method(:class).bind(val).call
|
59
|
+
!@never_wrap_types.any? { |c| klass <= c }
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
def never_wrap(type)
|
64
|
+
@never_wrap_types << type
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
def register_type(name, klass)
|
69
|
+
field_types[name] = klass
|
70
|
+
end
|
71
|
+
|
72
|
+
|
73
|
+
def plain_type?(type_name)
|
74
|
+
type_name.in?(PLAIN_TYPES)
|
75
|
+
end
|
76
|
+
|
77
|
+
|
78
|
+
def standard_class(name)
|
79
|
+
class_name = STANDARD_TYPES[name]
|
80
|
+
"HoboFields::#{class_name}".constantize if class_name
|
81
|
+
end
|
82
|
+
|
83
|
+
def enable
|
84
|
+
require "hobo_fields/enum_string"
|
85
|
+
require "hobo_fields/fields_declaration"
|
86
|
+
|
87
|
+
# Add the fields do declaration to ActiveRecord::Base
|
88
|
+
ActiveRecord::Base.send(:include, HoboFields::FieldsDeclaration)
|
89
|
+
|
90
|
+
# Monkey patch ActiveRecord so that the attribute read & write methods
|
91
|
+
# automatically wrap richly-typed fields.
|
92
|
+
ActiveRecord::AttributeMethods::ClassMethods.class_eval do
|
93
|
+
|
94
|
+
# Define an attribute reader method. Cope with nil column.
|
95
|
+
def define_read_method(symbol, attr_name, column)
|
96
|
+
cast_code = column.type_cast_code('v') if column
|
97
|
+
access_code = cast_code ? "(v=@attributes['#{attr_name}']) && #{cast_code}" : "@attributes['#{attr_name}']"
|
98
|
+
|
99
|
+
unless attr_name.to_s == self.primary_key.to_s
|
100
|
+
access_code = access_code.insert(0, "missing_attribute('#{attr_name}', caller) " +
|
101
|
+
"unless @attributes.has_key?('#{attr_name}'); ")
|
102
|
+
end
|
103
|
+
|
104
|
+
# This is the Hobo hook - add a type wrapper around the field
|
105
|
+
# value if we have a special type defined
|
106
|
+
src = if connected? && (type_wrapper = try.attr_type(symbol)) &&
|
107
|
+
type_wrapper.is_a?(Class) && type_wrapper.not_in?(HoboFields::PLAIN_TYPES.values)
|
108
|
+
"val = begin; #{access_code}; end; " +
|
109
|
+
"if HoboFields.can_wrap?(val); self.class.attr_type(:#{attr_name}).new(val); else; val; end"
|
110
|
+
else
|
111
|
+
access_code
|
112
|
+
end
|
113
|
+
|
114
|
+
evaluate_attribute_method(attr_name,
|
115
|
+
"def #{symbol}; @attributes_cache['#{attr_name}'] ||= begin; #{src}; end; end")
|
116
|
+
end
|
117
|
+
|
118
|
+
def define_write_method(attr_name)
|
119
|
+
src = if connected? && (type_wrapper = try.attr_type(attr_name)) &&
|
120
|
+
type_wrapper.is_a?(Class) && type_wrapper.not_in?(HoboFields::PLAIN_TYPES.values)
|
121
|
+
"begin; wrapper_type = self.class.attr_type(:#{attr_name}); " +
|
122
|
+
"if !val.is_a?(wrapper_type) && HoboFields.can_wrap?(val); wrapper_type.new(val); else; val; end; end"
|
123
|
+
else
|
124
|
+
"val"
|
125
|
+
end
|
126
|
+
evaluate_attribute_method(attr_name,
|
127
|
+
"def #{attr_name}=(val); write_attribute('#{attr_name}', #{src});end", "#{attr_name}=")
|
128
|
+
|
129
|
+
end
|
130
|
+
|
131
|
+
end
|
132
|
+
|
133
|
+
|
134
|
+
end
|
135
|
+
|
136
|
+
end
|
137
|
+
|
138
|
+
|
139
|
+
HoboFields.enable if defined? ActiveRecord
|
data/lib/hobofields.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'hobo_fields'
|