1# Copyright (c) 2006-2008, The RubyCocoa Project.
2# Copyright (c) 2001-2006, FUJIMOTO Hisakuni.
3# All Rights Reserved.
4#
5# RubyCocoa is free software, covered under either the Ruby's license or the 
6# LGPL. See the COPYRIGHT file for more information.
7
8require 'osx/cocoa'
9begin
10  # for testing old AR
11  # require '/System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/lib/ruby/gems/1.8/gems/activesupport-1.4.2/lib/active_support.rb'
12  # require '/System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/lib/ruby/gems/1.8/gems/activerecord-1.15.3/lib/active_record.rb'
13  
14  require 'active_record'
15rescue LoadError
16  msg = "ActiveRecord was not found, if you have it installed as a gem you have to require 'rubygems' before you require 'osx/active_record'"
17  $stderr.puts msg
18  raise LoadError, msg
19end
20
21# ---------------------------------------------------------
22# Class additions
23# ---------------------------------------------------------
24
25class ActiveRecord::Base
26  class << self
27    alias_method :__inherited_before_proxy, :inherited
28    def inherited(klass)
29      proxy_klass = "#{klass.to_s}Proxy"
30      unless klass.parent.local_constants.include?(proxy_klass)
31        eval "class ::#{proxy_klass} < OSX::ActiveRecordProxy; end;"
32        # FIXME: This leads to a TypeError originating from: oc_import.rb:618:in `method_added'
33        # Object.const_set(proxy_klass, Class.new(OSX::ActiveRecordProxy))
34      end
35      __inherited_before_proxy(klass)
36    end
37  end
38  
39  # Returns a proxy for this instance.
40  def to_activerecord_proxy
41    if self.class.instance_variable_get(:@proxy_klass).nil?
42      self.class.instance_variable_set(:@proxy_klass, Object.const_get("#{self.class.to_s}Proxy"))
43    end
44    @record_proxy ||= self.class.instance_variable_get(:@proxy_klass).alloc.initWithRecord(self)
45  end
46  alias_method :to_activerecord_proxies, :to_activerecord_proxy
47end
48
49class Array
50  # Returns an array with proxies for all the original records in this array.
51  def to_activerecord_proxies
52    map { |rec| rec.to_activerecord_proxy }
53  end
54  alias_method :to_activerecord_proxy, :to_activerecord_proxies
55  
56  # Returns an array with all the original records for the proxies in this array.
57  def original_records
58    map { |rec| rec.original_record }
59  end
60end
61
62module OSX
63  
64  # ---------------------------------------------------------
65  # Subclasses of cocoa classes that add ActiveRecord support
66  # ---------------------------------------------------------
67  
68  class ActiveRecordSetController < OSX::NSArrayController
69    # First tries to destroy the record(s) then lets the super method do it's work.
70    def remove(sender)
71      super_remove(sender) if selectedObjects.all? {|proxy| proxy.destroy }
72    end
73    
74    # Directly saves the new record.
75    def newObject
76      objectClass.class.create
77    end
78    
79    # Sets up the ActiveRecordSetController for a given model and sets the content if it's specified.
80    #
81    # <tt>:model</tt> should be the model that we'll be handling.
82    # <tt>:content</tt> is optional, if specified it should be an array with or without any proxies for the model that we're handling.
83    def setup_for(options)
84      raise ArgumentError, ":model was nil, expected a ActiveRecord::Base subclass" if options[:model].nil?
85      # FIXME: not DRY, duplicated from ActiveRecord::Base#to_activerecord_proxy
86      self.setObjectClass( Object.const_get("#{options[:model].to_s}Proxy") )
87      self.setContent(options[:content]) unless options[:content].nil?
88    end
89  end
90  
91  class BelongsToActiveRecordSetController < ActiveRecordSetController
92    # Doesn't save the record to the db in a belongs to association,
93    # because it will automatically be saved by ActiveRecord when added to the collection.
94    def newObject
95      objectClass.class.alloc.init
96    end
97  end
98  
99  class ActiveRecordTableView < OSX::NSTableView
100    require 'active_support/core_ext/string/inflections'
101    include ActiveSupport::CoreExtensions::String::Inflections
102
103    # <tt>:model</tt> should be the model class that you want to scaffold.
104    # <tt>:bind_to</tt> should be the ActiveRecordSetController instance that you want the columns to be bound too.
105    # <tt>:except</tt> can be either a string or an array that says which columns should not be displayed.
106    # <tt>:validates_immediately</tt> set this to +true+ to add validation to every column. Defaults to +false+.
107    #
108    #   ib_outlets :customersTableView, :customersRecordSetController
109    #  
110    #   def awakeFromNib
111    #     @customersTableView.scaffold_columns_for :model => Customer, :bind_to => @customersRecordSetController, :validates_immediately => true, :except => "id"
112    #   end
113    #
114    # You can also pass it a block which will yield 2 objects for every column, namely +table_column+ which is the new NSTableColumn
115    # and +column_opyions+ which is a hash that can be used to set additional options for the binding.
116    #
117    #   ib_outlets :customersTableView, :customersRecordSetController
118    #  
119    #   def awakeFromNib
120    #     @customersTableView.scaffold_columns_for :model => Customer, :bind_to => @customersRecordSetController do |table_column, column_options|
121    #       p table_column
122    #       p column_options
123    #       column_options['NSValidatesImmediately'] = true
124    #     end
125    #   end
126    def scaffold_columns_for(options)
127      raise ArgumentError, ":model was nil, expected a ActiveRecord::Base subclass" if options[:model].nil?
128      raise ArgumentError, ":bind_to was nil, expected an instance of ActiveRecordSetController" if options[:bind_to].nil?
129      options[:except] ||= []
130      options[:validates_immediately] ||= false
131      
132      # if there are any columns already, first remove them.
133      cols = self.tableColumns
134      if cols.count > 0
135        # we create a temporary array because we do not want to mutate the
136        # original one during the enumeration
137        tmpCols = OSX::NSArray.arrayWithArray(cols)
138        tmpCols.each { |column| self.removeTableColumn(column) }
139      end
140      
141      options[:model].column_names.each do |column_name|
142        # skip columns
143        next if options[:except].include?(column_name)
144        # setup new table column
145        table_column = OSX::NSTableColumn.alloc.init
146        table_column.setIdentifier(column_name)
147        table_column.headerCell.setStringValue(column_name.titleize)
148        # create a hash that will hold the options that will be passed as options to the bind method
149        column_options = {}
150        column_options['NSValidatesImmediately'] = options[:validates_immediately]
151        
152        # FIXME: causes a bus error on my machine...
153        yield(table_column, column_options) if block_given?
154        
155        # set the binding
156        table_column.bind_toObject_withKeyPath_options(OSX::NSValueBinding, options[:bind_to], "arrangedObjects.#{column_name}", column_options)
157        # and add it to the table view
158        self.addTableColumn(table_column)
159      end
160    end
161  end
162  
163  class ActiveRecordProxy < OSX::NSObject
164    
165    # class methods
166    class << self
167      # Use this class method to set any filters you need when a specific value for a key is requested.
168      # You can pass it a block, or a hash that contains either the key:
169      # - <tt>:return</tt> which needs an array that holds the class to be instantiated as the first element
170      # and the method to be called if the data isn't nil as the second element.
171      # If the data is nil the class will simply be instantiated with the normal alloc.init call.
172      # - <tt>:call</tt> which needs the method that it should call. When the method is called the data is sent as the argument.
173      #
174      #   class EmailProxy < OSX::ActiveRecordProxy
175      #     # on_get filter with: block
176      #     on_get :body do |content|
177      #       content ||= 'I'm so empty'
178      #       OSX::NSAttributedString.alloc.initWithString(content)
179      #     end
180      # 
181      #     # on_get filter with: return
182      #     on_get :subject, :return => [OSX::NSAttributedString, :initWithString]
183      # 
184      #     # on_get filter with: call
185      #     on_get :address, :call => :nsattributed_string_from_address
186      #     # and the method to be called
187      #     def nsattributed_string_from_address(address)
188      #       address ||= 'Emptier than this isn't possible'
189      #       OSX::NSAttributedString.alloc.initWithString(address)
190      #     end
191      #   end
192      def on_get(key, options={}, &block)
193        @on_get_filters ||= {}
194        @on_get_filters[key.to_sym] = ( block.nil? ? options : block )
195      end
196      
197      # This find class method passes the message on to the model, but it will return proxies for the returned records
198      def find(*args)
199        model_class.find(*args).to_activerecord_proxies
200      end
201      
202      # This method_missing class method passes the find_by_ message on to the model, but it will return proxies for the returned records
203      def method_missing(method, *args)
204        if method.to_s.index('find_by_') == 0
205          model_class.send(method, *args).to_activerecord_proxies
206        else
207          super
208        end
209      end
210      
211      # Returns the model class for this proxy
212      def model_class
213        @model_class ||= Object.const_get(self.to_s[0..-6])
214      end
215      
216      def create(attributes = {})
217        alloc.initWithAttributes(attributes)
218      end
219    end
220    
221    # Creates a new record and returns a proxy for it.
222    def init
223      if super_init
224        @record = self.record_class.send(:new) unless @record
225        define_record_methods! unless self.class.instance_variable_get(:@record_methods_defined)
226        self
227      end
228    end
229    
230    # Takes an existing record as an argument and returns a proxy for it.
231    def initWithRecord(record)
232      @record = record
233      init
234    end
235    
236    # Creates a new record with the given attributes and returns a proxy for it.
237    def initWithAttributes(attributes)
238      @record = record_class.send(:new, attributes)
239      return nil unless @record.save
240      init
241    end
242    
243    # Returns the model class for this proxy
244    def record_class
245      self.class.model_class
246    end
247  
248    # Returns an Array of all the available methods on the corresponding record object
249    def record_methods
250      @record.methods
251    end
252  
253    # Returns the corresponding record object
254    def to_activerecord
255      @record
256    end
257    # Returns the corresponding record object
258    def original_record
259      @record
260    end
261    
262    # Useful inspect method for use as: p(my_proxy)
263    def inspect
264      @record.inspect.sub(/#{record_class}/, "#{self.class.name.to_s} proxy_object_id: #{self.object_id} record_object_id: #{@record.object_id}")
265    end
266    
267    # Compare two ActiveRecord proxies. They are compared by the record.
268    def ==(other)
269      if self.class == other.class
270        self.original_record == other.original_record
271      else
272        super
273      end
274    end
275    
276    # Returns +true+ if the given key is an association, otherwise returns +false+
277    def is_association?(key)
278      key_sym = key.to_s.to_sym
279      @record.class.reflect_on_all_associations.each { |assoc| return true if assoc.name == key_sym }
280      false
281    end
282  
283    # KVC stuff
284    
285    # Get the filter for a given key if it exists.
286    def on_get_filter_for_key(key)
287      filters = self.class.instance_variable_get(:@on_get_filters)
288      filters[key.to_sym] unless filters.nil?
289    end
290    
291    # This method is called by the object that self is bound to,
292    # if the requested key is a association return proxies for the records.
293    def rbValueForKey(key)
294      if is_association?(key)
295        @record.send(key.to_s.to_sym).to_activerecord_proxies
296      else
297        if filter = self.on_get_filter_for_key(key)
298          if filter.is_a?(Hash)
299            case filter.keys.first
300            when :return
301              klass, method = filter[:return]
302              data = @record[key.to_s]
303              return (data.nil? ? klass.alloc.init : klass.alloc.send(method.to_sym, data))
304            when :call # callback method
305              send(filter[:call], @record[key.to_s])
306            end
307          elsif filter.is_a?(Proc)
308            filter.call(@record[key.to_s])
309          end
310        else
311          # no filter, so simply return the data
312          @record[key.to_s]
313        end
314      end
315    end
316  
317    # This method is called by the object that self is bound to,
318    # it's called when a update has occured.
319    def rbSetValue_forKey(value, key)
320      if is_association? key
321        # we are dealing with an association (only has_many for now)
322        if @record.send(key.to_s.to_sym).length < value.to_a.length
323          # add the newest record to the has_many association of the @record
324          return true if (@record.send(key.to_s.to_sym) << value.to_a.last.to_activerecord)
325        else
326          # reload the children to reflect the changes deletion of records
327          @record.reload
328          return true
329        end
330      else
331        @record[key.to_s] = value.to_ruby rescue nil
332        return @record.save
333      end
334      return false
335    end
336  
337    # This method is called by the object that self is bound to,
338    # it passes the value on to the record object and returns the validation result.
339    def validateValue_forKeyPath_error(value, key, error)
340      original_value = @record[key.to_s]
341      @record[key.to_s] = value[0].to_s
342      @record.valid?
343      
344      # we only want to check if the value for this attribute is valid and not every attribute
345      return true if @record.errors[key.to_s].nil?
346
347      @record[key.to_s] = original_value
348      # create a error message for each validation error on this attribute
349      error_msg = ''
350      @record.errors[key.to_s].each do |err|
351        error_msg += "#{self.record_class} #{key.to_s} #{err}\n"
352      end
353      # construct the NSError object
354      error.assign( OSX::NSError.alloc.initWithDomain_code_userInfo( OSX::NSCocoaErrorDomain, -1, { OSX::NSLocalizedDescriptionKey => error_msg } ) )
355      false
356    end
357
358    private
359    
360    def define_record_methods!
361      # define all the record attributes getters and setters
362      @record.attribute_names.each do |m|
363        self.class.class_eval do
364          define_method(m) do ||
365            #return @record.send(m)
366            return rbValueForKey(m.to_s)
367          end
368          sym = "#{m}=".to_sym
369          define_method(sym) do |*args|
370            return @record.send(sym, *args)
371          end
372        end
373      end
374      # define the normal instance methods of the record
375      (@record.methods - self.methods).each do |m|
376        next if m == 'initialize'
377        self.class.class_eval do
378          define_method(m) do |*args|
379            if is_association?(m)
380              return rbValueForKey(m)
381            else
382              return @record.send(m, *args)
383            end
384          end
385        end
386      end
387      self.class.instance_variable_set(:@record_methods_defined, true)
388    end
389  end
390
391  # ---------------------------------------------------------
392  # Extra support classes/modules
393  # ---------------------------------------------------------
394  
395  module ActiveRecordConnector
396    def connect_to_sqlite(dbfile, options = {})
397      options[:log] ||= false
398
399      if options[:log]
400        ActiveRecord::Base.logger = Logger.new($stderr)
401        ActiveRecord::Base.colorize_logging = false
402      end
403
404      # Connect to the database
405      ActiveRecord::Base.establish_connection({
406        :adapter => 'sqlite3',
407        :dbfile => dbfile
408      })
409    end
410    module_function :connect_to_sqlite
411    
412    # Connect to an SQLite database stored in the applications support directory ~/USER/Library/Application Support/APP/APP.sqlite.
413    # <tt>:always_migrate</tt> Always run migrations when this method is invoked, false by default.
414    # <tt>:migrations_dir</tt> The directory where migrations are stored, migrate/ by default.
415    # <tt>:log</tt> Log database activity, false by default.
416    #
417    #   ActiveRecordConnector.connect_to_sqlite_in_application_support :log => true
418    #
419    # If you run this for the first time and haven't already created a migration to create your database
420    # tables, etc., you'll need to force the migration if :always_migrate isn't enabled.
421    def connect_to_sqlite_in_application_support(options = {})
422      options[:always_migrate] ||= false
423      options[:migrations_dir] ||= 'migrate/'
424
425      dbfile = File.join(self.get_app_support_path, "#{self.get_app_name}.sqlite")
426      # connect
427      self.connect_to_sqlite(dbfile, options)
428      # do any necessary migrations
429      if not File.exists?(dbfile) or options[:always_migrate]
430        migrations_dir = File.join(OSX::NSBundle.mainBundle.resourcePath.fileSystemRepresentation.to_s, options[:migrations_dir])
431        # do a migration to the latest version
432        ActiveRecord::Migrator.migrate(migrations_dir, nil)
433      end
434    end
435    module_function :connect_to_sqlite_in_application_support
436    
437    def get_app_name
438      OSX::NSBundle.mainBundle.bundleIdentifier.to_s.scan(/\w+$/).first
439    end
440    module_function :get_app_name
441    
442    def get_app_support_path
443      # get the path to the ~/Library/Application Support/ directory
444      user_app_support_path = File.join(OSX::NSSearchPathForDirectoriesInDomains(OSX::NSLibraryDirectory, OSX::NSUserDomainMask, true)[0].to_s, "Application Support")
445      # get the complete path to the directory that will hold the files for this app.
446      # e.g.: ~/Library/Application Support/SomeApp/
447      path_to_this_apps_app_support_dir = File.join(user_app_support_path, self.get_app_name)
448      # and create it if necessary
449      unless File.exists?(path_to_this_apps_app_support_dir)
450        require 'fileutils'
451        FileUtils.mkdir_p(path_to_this_apps_app_support_dir)
452      end
453      return path_to_this_apps_app_support_dir
454    end
455    module_function :get_app_support_path
456  end
457
458end
459