Browse files

Rewrote internals to provide setters and getters for attributes. That…

… allows for type casting.

Added default values for columns.
Gemified 0.2
  • Loading branch information...
1 parent f0a823c commit 9c28692092254fcebdc7c670e630e572f26c49ee @sxross committed Sep 5, 2012
Showing with 431 additions and 165 deletions.
  1. +12 −0 CHANGELOG
  2. +29 −2 README.md
  3. +1 −1 Rakefile
  4. +1 −1 app/app_delegate.rb
  5. +118 −0 lib/motion_model/finder_query.rb
  6. +160 −141 lib/motion_model/model.rb
  7. +110 −20 spec/model_spec.rb
View
12 CHANGELOG
@@ -0,0 +1,12 @@
+2012-09-05: Basically rewrote how the data is stored.
+
+The API remains consistent, but a certain amount of
+efficiency is added by adding hashes to map column names
+to the column metadata.
+
+* Type casting now works, and is a function of initialization
+ and of assignment.
+
+* Default values have been added to fill in values
+ if not specified in new or create.
+
View
31 README.md
@@ -23,9 +23,9 @@ are:
- input_helpers: Hooking an array up to a data form, populating
it, and retrieving the data afterwards can be a bunch of code.
Not something I'd like to write more often that I have to. These
- helpers are certainly not the focus of this strawman release, but
+ helpers are certainly not the focus of this release, but
I am using these in an app to create Apple-like input forms in
- static tables. I expect some churn in this module.
+ static tables.
What Model Can Do
================
@@ -50,6 +50,17 @@ class MyCoolController
end
```
+Models support default values, so if you specify your model like this, you get defaults:
+
+```ruby
+class Task
+ include MotionModel::Model
+
+ columns :name => :string,
+ :due_date => {:type => :date, :default => '2012-09-15'}
+end
+```
+
You can also include the `Validations` module to get field validation. For example:
```ruby
@@ -74,6 +85,16 @@ class MyCoolController
end
```
+*Important Note*: Type casting occurs at initialization and on assignment. That means
+If you have a field type `int`, it will be changed from a string to an integer when you
+initialize the object of your class type or when you assign to the integer field in your class.
+
+```ruby
+a_task = Task.create(:name => 'joe-bob', :due_date => '2012-09-15') # due_date is cast to NSDate
+
+a_task.due_date = '2012-09-19' # due_date is cast to NSDate
+```
+
Model Instances and Unique IDs
-----------------
@@ -168,3 +189,9 @@ Things In The Pipeline
- Testing relations
- Adding validations and custom validations
- Did I say more tests?
+
+Problems/Comments
+------------------
+
+Please raise an issue if you find something that doesn't work, some
+syntax that smells, etc.
View
2 Rakefile
@@ -6,5 +6,5 @@ Motion::Project::App.setup do |app|
# Use `rake config' to see complete project settings.
app.delegate_class = 'FakeDelegate'
app.files = Dir.glob('./lib/motion_model/**/*.rb')
- app.files = (Dir.glob('./app/**/*.rb') + app.files).uniq
+ app.files = (app.files + Dir.glob('./app/**/*.rb')).uniq
end
View
2 app/app_delegate.rb
@@ -1,2 +1,2 @@
class FakeDelegate
-end
+end
View
118 lib/motion_model/finder_query.rb
@@ -0,0 +1,118 @@
+module MotionModel
+ class FinderQuery
+ attr_accessor :field_name
+
+ def initialize(*args)
+ @field_name = args[0] if args.length > 1
+ @collection = args.last
+ end
+
+ def and(field_name)
+ @field_name = field_name
+ self
+ end
+
+ def order(field = nil, &block)
+ if block_given?
+ @collection = @collection.sort{|o1, o2| yield(o1, o2)}
+ else
+ raise ArgumentError.new('you must supply a field name to sort unless you supply a block.') if field.nil?
+ @collection = @collection.sort{|o1, o2| o1.send(field) <=> o2.send(field)}
+ end
+ self
+ end
+
+ ######## relational methods ########
+ def do_comparison(query_string, options = {:case_sensitive => false})
+ query_string = query_string.downcase if query_string.respond_to?(:downcase) && !options[:case_sensitive]
+ @collection = @collection.select do |item|
+ comparator = item.send(@field_name.to_sym)
+ yield query_string, comparator
+ end
+ self
+ end
+
+ def contain(query_string, options = {:case_sensitive => false})
+ do_comparison(query_string) do |comparator, item|
+ if options[:case_sensitive]
+ item =~ Regexp.new(comparator, Regexp::MULTILINE)
+ else
+ item =~ Regexp.new(comparator, Regexp::IGNORECASE | Regexp::MULTILINE)
+ end
+ end
+ end
+ alias_method :contains, :contain
+ alias_method :like, :contain
+
+ def eq(query_string, options = {:case_sensitive => false})
+ do_comparison(query_string, options) do |comparator, item|
+ comparator == item
+ end
+ end
+ alias_method :==, :eq
+ alias_method :equal, :eq
+
+ def gt(query_string, options = {:case_sensitive => false})
+ do_comparison(query_string, options) do |comparator, item|
+ comparator > item
+ end
+ end
+ alias_method :>, :gt
+ alias_method :greater_than, :gt
+
+ def lt(query_string, options = {:case_sensitive => false})
+ do_comparison(query_string, options) do |comparator, item|
+ comparator < item
+ end
+ end
+ alias_method :<, :lt
+ alias_method :less_than, :lt
+
+ def gte(query_string, options = {:case_sensitive => false})
+ do_comparison(query_string, options) do |comparator, item|
+ comparator >= item
+ end
+ end
+ alias_method :>=, :gte
+ alias_method :greater_than_or_equal, :gte
+
+
+ def lte(query_string, options = {:case_sensitive => false})
+ do_comparison(query_string, options) do |comparator, item|
+ comparator <= item
+ end
+ end
+ alias_method :<=, :lte
+ alias_method :less_than_or_equal, :lte
+
+ def ne(query_string, options = {:case_sensitive => false})
+ do_comparison(query_string, options) do |comparator, item|
+ comparator != item
+ end
+ end
+ alias_method :!=, :ne
+ alias_method :not_equal, :ne
+
+ ########### accessor methods #########
+ def first
+ @collection.first
+ end
+
+ def last
+ @collection.last
+ end
+
+ def all
+ @collection
+ end
+
+ # each is a shortcut method to turn a query into an iterator. It allows
+ # you to write code like:
+ #
+ # Task.where(:assignee).eq('bob').each{ |assignee| do_something_with(assignee) }
+ def each(&block)
+ raise ArgumentError.new("each requires a block") unless block_given?
+ @collection.each{|item| yield item}
+ end
+ end
+end
View
301 lib/motion_model/model.rb
@@ -23,8 +23,7 @@
# Recognized types are:
#
# * :string
-# * :date (must be in a form that Date.parse can recognize)
-# * :time (must be in a form that Time.parse can recognize)
+# * :date (must be in YYYY-mm-dd form)
# * :integer
# * :float
#
@@ -37,72 +36,119 @@
# tasks_this_week = Task.where(:due_date).ge(beginning_of_week).and(:due_date).le(end_of_week)
# ordered_tasks_this_week = tasks_this_week.order(:due_date)
#
+
module MotionModel
module Model
+ class Column
+ attr_accessor :name
+ attr_accessor :type
+ attr_accessor :default
+
+ def initialize(name = nil, type = nil, default = nil)
+ @name = name
+ @type = type
+ @default = default || nil
+ end
+
+ def add_attr(name, type, default = nil)
+ @name = name
+ @type = type
+ @default = default || nil
+ end
+ alias_method :add_attribute, :add_attr
+ end
+
def self.included(base)
base.extend(ClassMethods)
- base.instance_variable_set("@column_attrs", [])
- base.instance_variable_set("@typed_attrs", [])
+ base.instance_variable_set("@_columns", [])
+ base.instance_variable_set("@_column_hashes", {})
base.instance_variable_set("@collection", [])
base.instance_variable_set("@_next_id", 1)
end
module ClassMethods
+ def add_field(name, options, default = nil) #nodoc
+ col = Column.new(name, options, default)
+ @_columns.push col
+ @_column_hashes[col.name.to_sym] = col
+ end
+
# Macro to define names and types of columns. It can be used in one of
# two forms:
#
# Pass a hash, and you define columns with types. E.g.,
#
# columns :name => :string, :age => :integer
+ #
+ # Pass a hash of hashes and you can specify defaults such as:
+ #
+ # columns :name => {:type => :string, :default => 'Joe Bob'}, :age => :integer
#
# Pass an array, and you create column names, all of which have type +:string+.
#
# columns :name, :age, :hobby
+
def columns(*fields)
- return @column_attrs if fields.empty?
+ return @_columns.map{|c| c.name} if fields.empty?
+ col = Column.new
+
case fields.first
when Hash
- fields.first.each_pair do |attr, type|
- add_attribute(attr, type)
+ fields.first.each_pair do |name, options|
+ case options
+ when Symbol, String
+ add_field(name, options)
+ when Hash
+ add_field(name, options[:type], options[:default])
+ else
+ raise ArgumentError.new("arguments to fields must be a symbol, a hash, or a hash of hashes.")
+ end
end
else
- fields.each do |attr|
- add_attribute(attr, :string)
+ fields.each do |name|
+ add_field(name, :string)
end
end
unless self.respond_to?(:id)
- add_attribute(:id, :integer)
+ add_field(:id, :integer)
end
end
-
- def add_attribute(attr, type) #nodoc
- attr_accessor attr
- @column_attrs << attr
- @typed_attrs << type
+
+ # Returns a column denoted by +name+
+ def column_named(name)
+ @_column_hashes[name.to_sym]
end
+ # Returns next available id
def next_id #nodoc
@_next_id
end
+ # Sets next available id
+ def next_id=(value)
+ @_next_id = value
+ end
+
+ # Increments next available id
def increment_id #nodoc
@_next_id += 1
end
# Returns true if a column exists on this model, otherwise false.
def column?(column)
- @column_attrs.each{|key|
- return true if key == column
- }
- false
+ respond_to?(column)
end
# Returns type of this column.
def type(column)
- index = @column_attrs.index(column)
- index ? @typed_attrs[index] : nil
+ column_named(column).type || nil
+ end
+
+ # returns default value for this column or nil.
+ def default(column)
+ column_named(column).default || nil
end
# Creates an object and saves it. E.g.:
@@ -112,12 +158,16 @@ def type(column)
# returns the object created or false.
def create(options = {})
row = self.new(options)
+ row.before_create if row.respond_to?(:before_create)
+ row.before_save if row.respond_to?(:before_save)
+
# TODO: Check for Validatable and if it's
# present, check valid? before saving.
- @collection.push(row)
+
+ row.save
row
end
-
+
def length
@collection.length
end
@@ -172,38 +222,83 @@ def each(&block)
####### Instance Methods #######
def initialize(options = {})
- columns.each{|col| instance_variable_set("@#{col.to_s}", nil) unless options.has_key?(col)}
+ @data ||= {}
- options.each do |key, value|
- instance_variable_set("@#{key.to_s}", value || '') if self.class.column?(key.to_sym)
- end
- unless self.id
- self.id = self.class.next_id
+ # Time zone, for future use.
+ @tz_offset ||= NSDate.date.to_s.gsub(/^.*?( -\d{4})/, '\1')
+
+ @cached_date_formatter = NSDateFormatter.alloc.init # Create once, as they are expensive to create
+ @cached_date_formatter.dateFormat = "yyyy-MM-dd HH:mm"
+
+ unless options[:id]
+ options[:id] = self.class.next_id
self.class.increment_id
+ else
+ self.class.next_id = [options[:id].to_i, self.class.next_id].max
+ end
+
+ columns.each do |col|
+ options[col] ||= self.class.default(col)
+ cast_value = cast_to_type(col, options[col])
+ @data[col] = cast_value
end
end
+ def cast_to_type(column_name, arg)
+ return nil if arg.nil?
+
+ return_value = arg
+
+ case type(column_name)
+ when :string
+ return_value = arg.to_s
+ when :int, :integer
+ return_value = arg.is_a?(Integer) ? arg : arg.to_i
+ when :float, :double
+ return_value = arg.is_a?(Float) ? arg : arg.to_f
+ when :date
+ return arg if arg.is_a?(NSDate)
+ date_string = arg += ' 00:00'
+ return_value = @cached_date_formatter.dateFromString(date_string)
+ else
+ raise ArgumentError.new("type #{column_name} : #{type(column_name)} is not possible to cast.")
+ end
+ return_value
+ end
+
+ def to_s
+ columns.each{|c| puts "#{c}: #{self.send(c)}"}
+ end
+
+ def save
+ self.class.instance_variable_get('@collection') << self
+ end
+
def length
@collection.length
end
alias_method :count, :length
def column?(target_key)
- self.class.column?(target_key)
+ self.class.column?(target_key.to_sym)
end
def columns
self.class.columns
end
+ def column_named(name)
+ self.class.column_named(name.to_sym)
+ end
+
def type(field_name)
self.class.type(field_name)
end
def initWithCoder(coder)
self.init
- self.class.instance_variable_get("@column_attrs").each do |attr|
+ self.class.instance_variable_get("@_columns").each do |attr|
# If a model revision has taken place, don't try to decode
# something that's not there.
new_tag_id = 1
@@ -222,125 +317,49 @@ def initWithCoder(coder)
end
def encodeWithCoder(coder)
- self.class.instance_variable_get("@column_attrs").each do |attr|
+ self.class.instance_variable_get("@_columns").each do |attr|
coder.encodeObject(self.send(attr), forKey: attr.to_s)
end
end
- end
-
- class FinderQuery
- attr_accessor :field_name
-
- def initialize(*args)
- @field_name = args[0] if args.length > 1
- @collection = args.last
- end
-
- def and(field_name)
- @field_name = field_name
- self
+ # Modify respond_to? to add model's attributes.
+ alias_method :old_respond_to?, :respond_to?
+ def respond_to?(method)
+ column_named(method) || old_respond_to?(method)
end
- def order(field = nil, &block)
- if block_given?
- @collection = @collection.sort{|o1, o2| yield(o1, o2)}
+ # Handle attribute retrieval
+ #
+ # Gets and sets work as expected, and type casting occurs
+ # For example:
+ #
+ # Task.date = '2012-09-15'
+ #
+ # This creates a real Date object in the data store.
+ #
+ # date = Task.date
+ #
+ # Date is a real date object.
+ def method_missing(method, *args, &block)
+ base_method = method.to_s.gsub('=', '').to_sym
+
+ col = column_named(base_method)
+
+ if col
+ if method.to_s.include?('=')
+ return @data[base_method] = self.cast_to_type(base_method, args[0])
+ else
+ return @data[base_method]
+ end
else
- raise ArgumentError.new('you must supply a field name to sort unless you supply a block.') if field.nil?
- @collection = @collection.sort{|o1, o2| o1.send(field) <=> o2.send(field)}
- end
- self
- end
-
- ######## relational methods ########
- def do_comparison(query_string)
- # TODO: Flag case-insensitive searching
- query_string = query_string.downcase if query_string.respond_to?(:downcase)
- @collection = @collection.select do |item|
- comparator = item.send(@field_name.to_sym)
- yield query_string, comparator
- end
- self
- end
-
- def contain(query_string)
- do_comparison(query_string) do |comparator, item|
- item =~ Regexp.new(comparator)
+ raise NoMethodError, <<ERRORINFO
+method: #{method}
+args: #{args.inspect}
+in: #{self.class.name}
+ERRORINFO
end
end
- alias_method :contains, :contain
- alias_method :like, :contain
-
- def eq(query_string)
- do_comparison(query_string) do |comparator, item|
- comparator == item
- end
- end
- alias_method :==, :eq
- alias_method :equal, :eq
-
- def gt(query_string)
- do_comparison(query_string) do |comparator, item|
- comparator > item
- end
- end
- alias_method :>, :gt
- alias_method :greater_than, :gt
-
- def lt(query_string)
- do_comparison(query_string) do |comparator, item|
- comparator < item
- end
- end
- alias_method :<, :lt
- alias_method :less_than, :lt
-
- def gte(query_string)
- do_comparison(query_string) do |comparator, item|
- comparator >= item
- end
- end
- alias_method :>=, :gte
- alias_method :greater_than_or_equal, :gte
-
-
- def lte(query_string)
- do_comparison(query_string) do |comparator, item|
- comparator <= item
- end
- end
- alias_method :<=, :lte
- alias_method :less_than_or_equal, :lte
-
- def ne(query_string)
- do_comparison(query_string) do |comparator, item|
- comparator != item
- end
- end
- alias_method :!=, :ne
- alias_method :not_equal, :ne
-
- ########### accessor methods #########
- def first
- @collection.first
- end
-
- def last
- @collection.last
- end
-
- def all
- @collection
- end
-
- # each is a shortcut method to turn a query into an iterator. It allows
- # you to write code like:
- #
- # Task.where(:assignee).eq('bob').each{ |assignee| do_something_with(assignee) }
- def each(&block)
- raise ArgumentError.new("each requires a block") unless block_given?
- @collection.each{|item| yield item}
- end
+
end
end
View
130 spec/model_spec.rb
@@ -10,6 +10,16 @@ class ATask
columns :name, :details, :some_day
end
+class TypeCast
+ include MotionModel::Model
+ columns :an_int => {:type => :int, :default => 3},
+ :an_integer => :integer,
+ :a_float => :float,
+ :a_double => :double,
+ :a_date => :date,
+ :a_time => :time
+end
+
describe "Creating a model" do
before do
Task.delete_all
@@ -18,8 +28,8 @@ class ATask
describe 'column macro behavior' do
it 'succeeds when creating a valid model from attributes' do
- a_task = Task.new(:name => 'name', :details => 'details')
- a_task.name.should.equal('name')
+ a_task = Task.new(:name => 'name', :details => 'details')
+ a_task.name.should.equal('name')
end
it 'creates a model with all attributes even if some omitted' do
@@ -28,45 +38,50 @@ class ATask
end
it 'simply bypasses spurious attributes erroneously set' do
- a_task = Task.new(:name => 'details', :zoo => 'very bad')
- a_task.should.not.respond_to(:zoo)
- a_task.name.should.equal('details')
+ a_task = Task.new(:name => 'details', :zoo => 'very bad')
+ a_task.should.not.respond_to(:zoo)
+ a_task.name.should.equal('details')
+ end
+
+ it "adds a default value if none supplied" do
+ a_type_test = TypeCast.new
+ a_type_test.an_int.should.equal(3)
end
it "can check for a column's existence on a model" do
- Task.column?(:name).should.be.true
+ Task.column?(:name).should.be.true
end
it "can check for a column's existence on an instance" do
- a_task = Task.new(:name => 'name', :details => 'details')
- a_task.column?(:name).should.be.true
+ a_task = Task.new(:name => 'name', :details => 'details')
+ a_task.column?(:name).should.be.true
end
it "gets a list of columns on a model" do
- cols = Task.columns
- cols.should.include(:name)
- cols.should.include(:details)
+ cols = Task.columns
+ cols.should.include(:name)
+ cols.should.include(:details)
end
it "gets a list of columns on an instance" do
- a_task = Task.new
- cols = a_task.columns
- cols.should.include(:name)
- cols.should.include(:details)
+ a_task = Task.new
+ cols = a_task.columns
+ cols.should.include(:name)
+ cols.should.include(:details)
end
it "columns can be specified as a Hash" do
- lambda{Task.new}.should.not.raise
- Task.new.column?(:name).should.be.true
+ lambda{Task.new}.should.not.raise
+ Task.new.column?(:name).should.be.true
end
it "columns can be specified as an Array" do
- lambda{ATask.new}.should.not.raise
- Task.new.column?(:name).should.be.true
+ lambda{ATask.new}.should.not.raise
+ Task.new.column?(:name).should.be.true
end
it "the type of a column can be retrieved" do
- Task.new.type(:some_day).should.equal(:date)
+ Task.new.type(:some_day).should.equal(:date)
end
end
@@ -155,6 +170,11 @@ class ATask
atask = Task.create(:name => 'find me', :details => "details 1")
found_task = Task.where(:details).contain("details 1").first.details.should.equal("details 1")
end
+
+ it 'handles case-sensitive queries' do
+ task = Task.create :name => 'Bob'
+ Task.find(:name).eq('bob', :case_sensitive => true).all.should.be.empty
+ end
it 'all returns all members of the collection as an array' do
Task.all.length.should.equal(10)
@@ -197,4 +217,74 @@ class ATask
end
end
+
+ describe 'Handling Attribute method_missing Implementation' do
+ it 'raises a NoMethodError exception when an unknown attribute it referenced' do
+ task = Task.new
+ lambda{task.bar}.should.raise(NoMethodError)
+ end
+ end
+
+ describe 'Type casting' do
+ before do
+ @convertible = TypeCast.new
+ @convertible.an_int = '1'
+ @convertible.an_integer = '2'
+ @convertible.a_float = '3.7'
+ @convertible.a_double = '3.41459'
+ @convertible.a_date = '2012-09-15'
+ end
+
+ it 'does the type casting on instantiation' do
+ @convertible.an_int.should.is_a Integer
+ @convertible.an_integer.should.is_a Integer
+ @convertible.a_float.should.is_a Float
+ @convertible.a_double.should.is_a Float
+ @convertible.a_date.should.is_a NSDate
+ end
+
+ it 'returns an integer for an int field' do
+ @convertible.an_int.should.is_a(Integer)
+ end
+
+ it 'the int field should be the same as it was in string form' do
+ @convertible.an_int.to_s.should.equal('1')
+ end
+
+ it 'returns an integer for an integer field' do
+ @convertible.an_integer.should.is_a(Integer)
+ end
+
+ it 'the integer field should be the same as it was in string form' do
+ @convertible.an_integer.to_s.should.equal('2')
+ end
+
+ it 'returns a float for a float field' do
+ @convertible.a_float.should.is_a(Float)
+ end
+
+ it 'the float field should be the same as it was in string form' do
+ @convertible.a_float.should.>(3.6)
+ @convertible.a_float.should.<(3.8)
+ end
+
+ it 'returns a double for a double field' do
+ @convertible.a_double.should.is_a(Float)
+ end
+
+ it 'the double field should be the same as it was in string form' do
+ @convertible.a_double.should.>(3.41458)
+ @convertible.a_double.should.<(3.41460)
+ end
+
+ it 'returns a NSDate for a date field' do
+ @convertible.a_date.should.is_a(NSDate)
+ end
+
+ it 'the date field should be the same as it was in string form' do
+ @convertible.a_date.to_s.should.match(/^2012-09-15/)
+ end
+
+ end
+
end

0 comments on commit 9c28692

Please sign in to comment.