lib/clevic/field.rb in clevic-0.8.0 vs lib/clevic/field.rb in clevic-0.11.1

- old
+ new

@@ -1,159 +1,348 @@ -require 'qtext/flags.rb' +require 'gather.rb' -require 'clevic/field_builder.rb' - module Clevic =begin rdoc This defines a field in the UI, and how it hooks up to a field in the DB. + +Attributes marked PROPERTY are DSL-style accessors, where the value can be +set with either an assignment or by passing a parameter. For example + property :ixnay + +will allow + + # reader + instance.ixnay + + #writer + instance.ixnay = 'nix, baby' + + #writer + instance.ixnay 'nix baby' + +Generally properties are for options that can be passed to the field creation +method in ModelBuilder, whereas ruby attributes are for the internal workings. + +#-- +TODO decide whether value_for type methods take an entity and do_something methods +take a value. + +TODO this class is a bit confused about whether it handles metadata or record data, or both. + +TODO meta needs to handle virtual fields better. Also is_date_time? =end class Field - include QtFlags + # For defining properties + include Gather - attr_accessor :attribute, :path, :label, :delegate, :class_name - attr_accessor :alignment, :format, :tooltip, :path_block - attr_accessor :visible + # The value to be displayed after being optionally format-ed + # + # Takes a String, a Symbol, or a Proc. + # + # A String will be a dot-separated path of attributes starting on the object returned by attribute. + # Paths longer than 1 element haven't been tested much. + # + # A Symbol refers to a method to be called on the current entity + # + # A Proc will be passed the current entity. This can be used to display 'virtual' + # fields from related tables, or calculated fields. + # + # Defaults to nil, in other words the value of the attribute for this field. + property :display - attr_writer :sample, :read_only + # The label to be displayed in the column headings. Defaults to the humanised field name. + property :label - # attribute is the symbol for the attribute on the model_class - def initialize( attribute, model_class, options ) + # For relational fields, this is the class_name for the related AR entity. + # TODO not used anymore? + property :class_name + + # One of the alignment specifiers - :left, :centre, :right or :justified. + # Defaults to right for numeric fields, centre for boolean, and left for + # other values. + property :alignment + + # something to do with the icon that Qt displays. Not implemented yet. + property :decoration + + # This defines how to format the value returned by :display. It takes a string or a Proc. + # Generally the string is something + # that can be understood by strftime (for time and date fields) or understood + # by % (for everything else). It can also be a Proc that has one parameter - + # the current entity. There are sensible defaults for common field types. + property :format + + # This is just like format, except that it's used to format the value just + # before it's edited. A good use of this is to display dates with a 2-digit year + # but edit them with a 4 digit year. + # Defaults to a sensible value for some fields, for others it will default to the value of :format. + property :edit_format + + # Whether the field is currently visible or not. + property :visible + + # Sample is used if the programmer wishes to provide a value (that will be converted + # using to_s) that can be used + # as the basis for calculating the width of the field. By default this will be + # calculated from the database, but this may be an expensive operation, and + # doesn't always work properly. So we + # have the option to override that if we wish. + property :sample + + # Takes a boolean. Set the field to read-only. + property :read_only + + # The foreground and background colors. + # Can take a Proc, a string, or a symbol. + # - A Proc is called with an entity + # - A String is treated as a constant which may be one of the string constants understood by Qt::Color + # - A symbol is treated as a method to be call on an entity + # + # The result can be a Qt::Color, or one of the strings in + # http://www.w3.org/TR/SVG/types.html#ColorKeywords. + property :foreground, :background + + # Can take a Proc, a string, or a symbol. + # - A Proc is called with an entity + # - A String is treated as a constant + # - A symbol is treated as a method to be call on an entity + property :tooltip + + # The set of allowed values for restricted fields. If it's a hash, the + # keys will be stored in the db, and the values displayed in the UI. + property :set + + # Only for the distinct field type. The values will be sorted either with the + # most used values first (:frequency => true) or in alphabetical order (:description => true). + property :frequency, :description + + # Not implemented. Default value for this field for new records. Not sure how to populate it though. + property :default + + # properties for ActiveRecord options + # There are actually from ActiveRecord::Base.VALID_FIND_OPTIONS, but it's protected + # each element becomes a property. + AR_FIND_OPTIONS = [ :conditions, :include, :joins, :limit, :offset, :order, :select, :readonly, :group, :from, :lock ] + AR_FIND_OPTIONS.each{|x| property x} + + # Return a list of find options and their values, but only + # if the values are not nil + def find_options + AR_FIND_OPTIONS.inject(Hash.new) do |ha,x| + option_value = self.send(x) + unless option_value.nil? + ha[x] = option_value + end + ha + end + end + + # The UI delegate class for the field. In Qt, this is a subclass of AbstractItemDelegate. + attr_accessor :delegate + + # The attribute on the AR entity that forms the basis for this field. + # Accessing the returned attribute (using send, or the [] method on an entity) + # will give a simple value, or another AR entity in the case of relational fields. + # In other words, this is *not* the same as the name of the field in the DB, which + # would normally have an _id suffix for relationships. + attr_accessor :attribute + + # The ActiveRecord::Base subclass this field uses to get data from. + attr_reader :entity_class + + # Create a new Field object that displays the contents of a database field in + # the UI using the given parameters. + # - attribute is the symbol for the attribute on the entity_class. + # - entity_class is the ActiveRecord::Base subclass which this Field talks to. + # - options is a hash of writable attributes in Field, which can be any of the properties defined in this class. + def initialize( attribute, entity_class, options, &block ) # sanity checking - unless model_class.has_attribute?( attribute ) or model_class.instance_methods.include?( attribute.to_s ) + unless attribute.is_a?( Symbol ) + raise "attribute #{attribute.inspect} must be a symbol" + end + + unless entity_class.ancestors.include?( ActiveRecord::Base ) + raise "entity_class must be a descendant of ActiveRecord::Base" + end + + unless entity_class.has_attribute?( attribute ) or entity_class.instance_methods.include?( attribute.to_s ) msg = <<EOF -#{attribute} not found in #{model_class.name}. Possibilities are: -#{model_class.attribute_names.join("\n")} +#{attribute} not found in #{entity_class.name}. Possibilities are: +#{entity_class.attribute_names.join("\n")} EOF raise msg end - # set values + # instance variables @attribute = attribute - @model_class = model_class + @entity_class = entity_class @visible = true - options.each do |key,value| - self.send( "#{key}=", value ) if respond_to?( "#{key}=" ) - end + # initialise + @value_cache = {} - # TODO could convert everything to a block here, even paths - if options[:display].kind_of?( Proc ) - @path_block = options[:display] - else - @path = options[:display] - end + # handle options + gather( options, &block ) - # default the label - @label ||= attribute.to_s.humanize - - # default formats - if @format.nil? - case meta.type - when :time; @format = '%H:%M' - when :date; @format = '%d-%h-%y' - when :datetime; @format = '%d-%h-%y %H:%M:%S' - when :decimal, :float; @format = "%.2f" - end - end - - # default alignments - if @alignment.nil? - @alignment = - case meta.type - when :decimal, :integer, :float; qt_alignright - when :boolean; qt_aligncenter - end - end + # set various sensible defaults. They're not lazy accessors because + # they might stay nil, and we don't want to keep evaluating them. + default_label! + default_format! + default_edit_format! + default_alignment! end - # Return the attribute value for the given entity, which will probably - # be an ActiveRecord instance + # Return the attribute value for the given ActiveRecord entity, or nil + # if entity is nil. Will call transform_attribute. def value_for( entity ) - return nil if entity.nil? - transform_attribute( entity.send( attribute ) ) + begin + return nil if entity.nil? + transform_attribute( entity.send( attribute ) ) + rescue Exception => e + puts "error for #{entity}.#{entity.send( attribute ).inspect} in value_for: #{e.message}" + puts e.backtrace + end end - # apply path, or path_block, to the given - # attribute value. Otherwise just return - # attribute_value itself + # Apply display, to the given + # attribute value. Otherwise just return the + # attribute_value itself. def transform_attribute( attribute_value ) return nil if attribute_value.nil? - case - when !path_block.nil? - path_block.call( attribute_value ) + case display + when Proc + display.call( attribute_value ) - when !path.nil? - attribute_value.evaluate_path( path.split( /\./ ) ) - + when String + attribute_value.evaluate_path( display.split( '.' ) ) + + when Symbol + attribute_value.send( display ) + else attribute_value end end # return true if this is a field for a related table, false otherwise. def is_association? meta.type == ActiveRecord::Reflection::AssociationReflection end - # return true if it's a date, a time or a datetime - # cache result because the type won't change in the lifetime of the field - def is_date_time? - @is_date_time ||= [:time, :date, :datetime].include?( meta.type ) + # Return true if the field is a date, a time or a datetime. + # If display is nil, the value is calculated, so we need + # to check the value. Otherwise use the field metadata. + # Cache the result for the first non-nil value. + def is_date_time?( value ) + if value.nil? + false + else + @is_date_time ||= + if display.nil? + [:time, :date, :datetime].include?( meta.type ) + else + # it's a virtual field, so we need to use the value + value.is_a?( Date ) || value.is_a?( Time ) + end + end end # return ActiveRecord::Base.columns_hash[attribute] # in other words an ActiveRecord::ConnectionAdapters::Column object, # or an ActiveRecord::Reflection::AssociationReflection object def meta - @model_class.columns_hash[attribute.to_s] || @model_class.reflections[attribute] + @meta ||= @entity_class.columns_hash[attribute.to_s] || @entity_class.reflections[attribute] end + + # return the type of this attribute. Usually one of :string, :integer, :float + # or some entity class (ActiveRecord::Base subclass) + def attribute_type + @attribute_type ||= + if meta.kind_of?( ActiveRecord::Reflection::MacroReflection ) + meta.klass + else + meta.type + end + end # return true if this field can be used in a filter # virtual fields (ie those that don't exist in this field's - # table) can't be filtered on. + # table) can't be used to filter on. def filterable? !meta.nil? end - # return the name of the field for this Field, quoted for the dbms + # Return the name of the database field for this Field, quoted for the dbms. def quoted_field - @model_class.connection.quote_column_name( meta.name ) + quote_field( meta.name ) end + + # Quote the given string as a field name for SQL. + def quote_field( field_name ) + @entity_class.connection.quote_column_name( field_name ) + end # return the result of the attribute + the path def column [attribute.to_s, path].compact.join('.') end # return an array of the various attribute parts def attribute_path pieces = [ attribute.to_s ] - pieces.concat( path.split( /\./ ) ) unless path.nil? + pieces.concat( display.to_s.split( '.' ) ) unless display.is_a? Proc pieces.map{|x| x.to_sym} end - # is the field read-only. Defaults to false. + # Return true if the field is read-only. Defaults to false. def read_only? @read_only || false end - # format this value. Use strftime for date_time types, or % for everything else - def do_format( value ) - if self.format != nil - if is_date_time? - value.strftime( format ) + # apply format to value. Use strftime for date_time types, or % for everything else. + # If format is a proc, pass value to it. + def do_generic_format( format, value ) + begin + unless format.nil? + if format.is_a? Proc + format.call( value ) + else + if is_date_time?( value ) + value.strftime( format ) + else + format % value + end + end else - self.format % value + value end - else - value + rescue Exception => e + puts "format: #{format.inspect}" + puts "value.class: #{value.class.inspect}" + puts "value: #{value.inspect}" + puts e.message + puts e.backtrace + nil end end - # return a sample for the field which can be used to size a column in the table - def sample + def do_format( value ) + do_generic_format( format, value ) + end + + def do_edit_format( value ) + do_generic_format( edit_format, value ) + end + + # return a sample for the field which can be used to size the UI field widget + def sample( *args ) + if !args.empty? + self.sample = *args + return + end + if @sample.nil? self.sample = case meta.type # max width of 40 chars when :string, :text @@ -166,74 +355,186 @@ numeric_sample # TODO return a width, or something like that when :boolean; 'W' - when ActiveRecord::Reflection::AssociationReflection - #TODO width for relations + when ActiveRecord::Reflection::AssociationReflection.class + related_sample else - puts "#{@model_class.name}.#{attribute} is a #{meta.type.inspect}" + puts "#{@entity_class.name}.#{attribute} is a #{meta.type.inspect}" end - if $options[:debug] - puts "@sample for #{@model_class.name}.#{attribute} #{meta.type}: #{@sample.inspect}" - end + #~ if $options && $options[:debug] + #~ puts "@sample for #{@entity_class.name}.#{attribute} #{meta.type}: #{@sample.inspect}" + #~ end end # if we don't know how to figure it out from the data, just return the label size @sample || self.label end + + # Called by Clevic::TableModel to get the tooltip value + def tooltip_for( entity ) + cache_value_for( :background, entity ) + end + # TODO Doesn't do anything useful yet. + def decoration_for( entity ) + nil + end + + # Convert something that responds to to_s to a Qt::Color, + # or just return the argument if it's already a Qt::Color + def string_or_color( s_or_c ) + case s_or_c + when Qt::Color + s_or_c + else + Qt::Color.new( s_or_c.to_s ) + end + end + + # Called by Clevic::TableModel to get the foreground color value + def foreground_for( entity ) + cache_value_for( :foreground, entity ) {|x| string_or_color(x)} + end + + # Called by Clevic::TableModel to get the background color value + def background_for( entity ) + cache_value_for( :background, entity ) {|x| string_or_color(x)} + end + +protected + + # call the conversion_block with the value, or just return the + # value if conversion_block is nil + def convert_or_identity( value, &conversion_block ) + if conversion_block.nil? + value + else + conversion_block.call( value ) + end + end + + # symbol is the property name to fetch a value for. + # It can be a Proc, a symbol, or a value responding to to_s. + # In all cases, conversion block will be called + # conversion_block takes the value expected back from the property + # and converts it to something that Qt will understand. Mostly + # this applies to non-strings, ie colors for foreground and background, + # and an icon resource for decoration - that kind of thing. + def cache_value_for( symbol, entity, &conversion_block ) + value = send( symbol ) + case value + when Proc; convert_or_identity( value.call( entity ), &conversion_block ) unless entity.nil? + when Symbol; convert_or_identity( entity.send( value ), &conversion_block ) unless entity.nil? + else; @value_cache[symbol] ||=convert_or_identity( value, &conversion_block ) + end + end + + def default_label! + @label ||= attribute.to_s.humanize + end + + def default_format! + if @format.nil? + @format = + case meta.type + when :time; '%H:%M' + when :date; '%d-%h-%y' + when :datetime; '%d-%h-%y %H:%M:%S' + when :decimal, :float; "%.2f" + end + end + @format + end + + def default_edit_format! + if @edit_format.nil? + @edit_format = + case meta.type + when :date; '%d-%h-%Y' + when :datetime; '%d-%h-%Y %H:%M:%S' + end || default_format! + end + @edit_format + end + + def default_alignment! + if @alignment.nil? + @alignment = + case meta.type + when :decimal, :integer, :float; :right + when :boolean; :centre + end + end + end + private def format_result( result_set ) unless result_set.size == 0 obj = result_set[0][attribute] - unless obj.nil? - do_format( obj ) - end + do_format( obj ) unless obj.nil? end end - def string_sample( max_sample = nil ) - result_set = @model_class.connection.execute <<-EOF - select distinct #{quoted_field} - from #{@model_class.table_name} + def string_sample( max_sample = nil, entity_class = @entity_class, field_name = meta.name ) + statement = <<-EOF + select distinct #{quote_field field_name} + from #{entity_class.table_name} where - length( #{quoted_field} ) = ( - select max( length( #{quoted_field} ) ) - from #{@model_class.table_name} + length( #{quote_field field_name} ) = ( + select max( length( #{quote_field field_name} ) ) + from #{entity_class.table_name} ) EOF + result_set = @entity_class.connection.execute statement unless result_set.entries.size == 0 - result = result_set[0][0] + row = result_set[0] + result = + case row + when Array + row[0] + when Hash + row.values[0] + end + if max_sample.nil? result else result.length < max_sample.length ? result : max_sample end end end def date_time_sample - result_set = @model_class.find_by_sql <<-EOF + result_set = @entity_class.find_by_sql <<-EOF select #{quoted_field} - from #{@model_class.table_name} + from #{@entity_class.table_name} where #{quoted_field} is not null limit 1 EOF format_result( result_set ) end def numeric_sample # TODO Use precision from metadata, not for integers # returns nil for floats. So it's probably not useful #~ puts "meta.precision: #{meta.precision.inspect}" - result_set = @model_class.find_by_sql <<-EOF + result_set = @entity_class.find_by_sql <<-EOF select max( #{quoted_field} ) - from #{@model_class.table_name} + from #{@entity_class.table_name} EOF format_result( result_set ) + end + + def related_sample + # TODO this isn't really the right way to do this + return nil if meta.nil? + if meta.klass.attribute_names.include?( attribute_path[1].to_s ) + string_sample( nil, meta.klass, attribute_path[1] ) + end end end end