diff --git a/README.textile b/README.textile index 969e52c..420793e 100644 --- a/README.textile +++ b/README.textile @@ -1,4 +1,4 @@ -h2. Postgres array support for activerecord +h2. Postgres array support for activerecord modified by VacationLabs Add basic support for postgres arrays to activerecord, with special attention to getting rails migrations / schema dumps working nicely. diff --git a/activerecord-postgres-array.gemspec b/activerecord-postgres-array.gemspec index bb43266..dde34ba 100644 --- a/activerecord-postgres-array.gemspec +++ b/activerecord-postgres-array.gemspec @@ -2,7 +2,7 @@ Gem::Specification.new do |s| s.name = "activerecord-postgres-array" s.version = "0.0.9" - s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= + s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to?(:required_rubygems_version=) s.authors = ["Tim Connor"] s.date = %q{2012-02-08} s.description = "Adds support for postgres arrays to ActiveRecord" diff --git a/lib/activerecord-postgres-array.rb b/lib/activerecord-postgres-array.rb index 3a02758..ff01410 100644 --- a/lib/activerecord-postgres-array.rb +++ b/lib/activerecord-postgres-array.rb @@ -14,4 +14,6 @@ class ActiveRecordPostgresArray < Rails::Railtie end require "activerecord-postgres-array/string" -require "activerecord-postgres-array/array" \ No newline at end of file +require "activerecord-postgres-array/array" +require "activerecord-postgres-array/parser" +require "activerecord-postgres-array/coder" diff --git a/lib/activerecord-postgres-array/activerecord.rb b/lib/activerecord-postgres-array/activerecord.rb index dd8be3b..4f965f6 100644 --- a/lib/activerecord-postgres-array/activerecord.rb +++ b/lib/activerecord-postgres-array/activerecord.rb @@ -5,27 +5,27 @@ class ArrayTypeMismatch < ActiveRecord::ActiveRecordError end class Base - def arel_attributes_values(include_primary_key = true, include_readonly_attributes = true, attribute_names = @attributes.keys) - attrs = {} - klass = self.class - arel_table = klass.arel_table + # def arel_attributes_values(include_primary_key = true, include_readonly_attributes = true, attribute_names = @attributes.keys) + # attrs = {} + # klass = self.class + # arel_table = klass.arel_table - attribute_names.each do |name| - if (column = column_for_attribute(name)) && (include_primary_key || !column.primary) - if include_readonly_attributes || !self.class.readonly_attributes.include?(name) - value = read_attribute(name) - if column.type.to_s =~ /_array$/ && value && value.is_a?(Array) - value = value.to_postgres_array(new_record?) - elsif klass.serialized_attributes.include?(name) - value = @attributes[name].serialized_value - end - attrs[arel_table[name]] = value - end - end - end + # attribute_names.each do |name| + # if (column = column_for_attribute(name)) && (include_primary_key || !column.primary) + # if include_readonly_attributes || !self.class.readonly_attributes.include?(name) + # value = read_attribute(name) + # if column.type.to_s =~ /_array$/ && value && value.is_a?(Array) + # value = value.to_postgres_array(new_record?) + # elsif coder = klass.serialized_attributes[name] + # value = coder.dump @attributes[name] + # end + # attrs[arel_table[name]] = value + # end + # end + # end - attrs - end + # attrs + # end end module ConnectionAdapters @@ -40,12 +40,12 @@ def native_database_types_with_array(*args) # Quotes a value for use in an SQL statement def quote_with_array(value, column = nil) if value && column && column.sql_type =~ /\[\]$/ - raise ArrayTypeMismatch, "#{column.name} must be an Array or have a valid array value (#{value})" unless value.kind_of?(Array) || value.valid_postgres_array? + # raise ArrayTypeMismatch, "#{column.name} must be an Array or have a valid array value (#{value})" unless value.kind_of?(Array) return value.to_postgres_array end quote_without_array(value,column) end - alias_method_chain :quote, :array + # alias_method_chain :quote, :array end class Table @@ -85,6 +85,19 @@ class TableDefinition end class PostgreSQLColumn < Column + + # + def type_cast_with_array(val) + if type.to_s =~ /_array$/ + base_type = type.to_s.gsub(/_array/, '') + val.nil? ? nil : val.from_postgres_array(base_type.parameterize('_').to_sym) + else + type_cast_without_array(val) + end + end + alias_method_chain :type_cast, :array + + # Does the type casting from array columns using String#from_postgres_array or Array#from_postgres_array. def type_cast_code_with_array(var_name) if type.to_s =~ /_array$/ @@ -98,7 +111,7 @@ def type_cast_code_with_array(var_name) # Adds the array type for the column. def simplified_type_with_array(field_type) - if field_type =~ /^numeric.+\[\]$/ + if field_type =~ /^numeric.*\[\]$/ :decimal_array elsif field_type =~ /character varying.*\[\]/ :string_array diff --git a/lib/activerecord-postgres-array/array.rb b/lib/activerecord-postgres-array/array.rb index d737fce..9c0c0e1 100644 --- a/lib/activerecord-postgres-array/array.rb +++ b/lib/activerecord-postgres-array/array.rb @@ -15,6 +15,12 @@ def to_postgres_array(omit_quotes = false) value elsif value.is_a?(NilClass) value = 'NULL' + elsif value.is_a?(Time) + value = "\"#{value.getutc.iso8601}\"" + elsif value.is_a?(TrueClass) + 't' + elsif value.is_a?(FalseClass) + 'f' else value end diff --git a/lib/activerecord-postgres-array/coder.rb b/lib/activerecord-postgres-array/coder.rb new file mode 100644 index 0000000..ff69d39 --- /dev/null +++ b/lib/activerecord-postgres-array/coder.rb @@ -0,0 +1,27 @@ +module ActiveRecord + module Coders + class PgArray + def self.load(arr) + new({}).load(arr) + end + + def self.dump(arr) + new({}).dump(arr) + end + + def initialize(base_type, default=nil) + @base_type = base_type + @default=default + end + + def dump(obj) + obj.nil? ? (@default.nil? ? nil : @default.to_postgres_array(true)) : obj.to_postgres_array(true) + end + + def load(arr) + arr.nil? ? @default : arr.from_postgres_array(@base_type) + end + end + end +end + diff --git a/lib/activerecord-postgres-array/parser.rb b/lib/activerecord-postgres-array/parser.rb new file mode 100644 index 0000000..4822162 --- /dev/null +++ b/lib/activerecord-postgres-array/parser.rb @@ -0,0 +1,112 @@ +class ActiveRecordPostgresArray < Rails::Railtie + # PostgreSQL array parser that handles all types of input. + # + # This parser is very simple and unoptimized, but should still + # be O(n) where n is the length of the input string. + class Parser + ARRAY = "ARRAY".freeze + DOUBLE_COLON = '::'.freeze + EMPTY_BRACKET = '[]'.freeze + OPEN_BRACKET = '['.freeze + CLOSE_BRACKET = ']'.freeze + COMMA = ','.freeze + BACKSLASH = '\\'.freeze + EMPTY_STRING = ''.freeze + OPEN_BRACE = '{'.freeze + CLOSE_BRACE = '}'.freeze + NULL = 'NULL'.freeze + QUOTE = '"'.freeze + # Current position in the input string. + attr_reader :pos + + # Set the source for the input, and any converter callable + # to call with objects to be created. For nested parsers + # the source may contain text after the end current parse, + # which will be ignored. + def initialize(source, converter=nil) + @source = source + @source_length = source.length + @converter = converter + @pos = -1 + @entries = [] + @recorded = "" + @dimension = 0 + end + + # Return 2 objects, whether the next character in the input + # was escaped with a backslash, and what the next character is. + def next_char + @pos += 1 + if (c = @source[@pos..@pos]) == BACKSLASH + @pos += 1 + [true, @source[@pos..@pos]] + else + [false, c] + end + end + + # Add a new character to the buffer of recorded characters. + def record(c) + @recorded << c + end + + # Take the buffer of recorded characters and add it to the array + # of entries, and use a new buffer for recorded characters. + def new_entry(include_empty=false) + if !@recorded.empty? || include_empty + entry = @recorded + if entry == NULL && !include_empty + entry = nil + elsif @converter + entry = @converter.call(entry) + end + @entries.push(entry) + @recorded = "" + end + end + + # Parse the input character by character, returning an array + # of parsed (and potentially converted) objects. + def parse(nested=false) + # quote sets whether we are inside of a quoted string. + quote = false + until @pos >= @source_length + escaped, char = next_char + if char == OPEN_BRACE && !quote + @dimension += 1 + if (@dimension > 1) + # Multi-dimensional array encounter, use a subparser + # to parse the next level down. + subparser = self.class.new(@source[@pos..-1], @converter) + @entries.push(subparser.parse(true)) + @pos += subparser.pos - 1 + end + elsif char == CLOSE_BRACE && !quote + @dimension -= 1 + if (@dimension == 0) + new_entry + # Exit early if inside a subparser, since the + # text after parsing the current level should be + # ignored as it is handled by the parent parser. + return @entries if nested + end + elsif char == QUOTE && !escaped + # If already inside the quoted string, this is the + # ending quote, so add the entry. Otherwise, this + # is the opening quote, so set the quote flag. + new_entry(true) if quote + quote = !quote + elsif char == COMMA && !quote + # If not inside a string and a comma occurs, it indicates + # the end of the entry, so add the entry. + new_entry + else + # Add the character to the recorded character buffer. + record(char) + end + end + raise "array dimensions not balanced" unless @dimension == 0 + @entries + end + end +end \ No newline at end of file diff --git a/lib/activerecord-postgres-array/string.rb b/lib/activerecord-postgres-array/string.rb index f3fd3c6..670b655 100644 --- a/lib/activerecord-postgres-array/string.rb +++ b/lib/activerecord-postgres-array/string.rb @@ -1,4 +1,10 @@ class String + PgStringRegexp = /[^",\\]+/ + PgQuotedStringRegexp = /"[^"\\]*(?:\\.[^"\\]*)*"/ + PgNumberRegexp = /[-+]?[0-9]*\.?[0-9]+/ + PgValidationRegexp = /\{\s*((#{PgNumberRegexp}|#{PgQuotedStringRegexp}|#{PgStringRegexp})(\s*\,\s*(#{PgNumberRegexp}|#{PgQuotedStringRegexp}|#{PgStringRegexp}))*)?\}/ + + # def to_postgres_array self end @@ -7,36 +13,27 @@ def to_postgres_array # * An empty string # * A string like '{10000, 10000, 10000, 10000}' # * TODO A multi dimensional array string like '{{"meeting", "lunch"}, {"training", "presentation"}}' - def valid_postgres_array? - string_regexp = /[^",\\]+/ - quoted_string_regexp = /"[^"\\]*(?:\\.[^"\\]*)*"/ - number_regexp = /[-+]?[0-9]*\.?[0-9]+/ - validation_regexp = /\{\s*((#{number_regexp}|#{quoted_string_regexp}|#{string_regexp})(\s*\,\s*(#{number_regexp}|#{quoted_string_regexp}|#{string_regexp}))*)?\}/ - !!match(/^\s*('#{validation_regexp}'|#{validation_regexp})?\s*$/) - end + # def valid_postgres_array? + # !!match(/^\s*('#{PgValidationRegexp}'|#{PgValidationRegexp})?\s*$/) + # true + # end # Creates an array from a postgres array string that postgresql spits out. def from_postgres_array(base_type = :string) if empty? [] else - elements = match(/\{(.*)\}/m).captures.first.gsub(/\\"/, '$ESCAPED_DOUBLE_QUOTE$').split(/(?:,)(?=(?:[^"]|"[^"]*")*$)/m) - elements = elements.map do |e| - res = e.gsub('$ESCAPED_DOUBLE_QUOTE$', '"').gsub("\\\\", "\\").gsub(/^"/, '').gsub(/"$/, '').gsub("''", "'").strip - res == 'NULL' ? nil : res + converter = case base_type + when :decimal then Proc.new {|x| x.to_d } + when :float then Proc.new {|x| x.to_f } + when :integer then Proc.new {|x| x.to_i } + when :timestamp then Proc.new {|x| x.to_time.in_time_zone } + when :boolean then Proc.new {|x| (x.downcase=='t' || x==true) ? true : false } + else Proc.new {|x| x } end - if base_type == :decimal - elements.collect(&:to_d) - elsif base_type == :float - elements.collect(&:to_f) - elsif base_type == :integer || base_type == :bigint - elements.collect(&:to_i) - elsif base_type == :timestamp - elements.collect(&:to_time) - else - elements - end + parser = ActiveRecordPostgresArray::Parser.new(self, converter) + return parser.parse end end end