Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for parsing multi-dimensional array string #33

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.textile
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion activerecord-postgres-array.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion lib/activerecord-postgres-array.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ class ActiveRecordPostgresArray < Rails::Railtie
end

require "activerecord-postgres-array/string"
require "activerecord-postgres-array/array"
require "activerecord-postgres-array/array"
require "activerecord-postgres-array/parser"
require "activerecord-postgres-array/coder"
57 changes: 35 additions & 22 deletions lib/activerecord-postgres-array/activerecord.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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$/
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/activerecord-postgres-array/array.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions lib/activerecord-postgres-array/coder.rb
Original file line number Diff line number Diff line change
@@ -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

112 changes: 112 additions & 0 deletions lib/activerecord-postgres-array/parser.rb
Original file line number Diff line number Diff line change
@@ -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 [email protected]? || 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
41 changes: 19 additions & 22 deletions lib/activerecord-postgres-array/string.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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