Skip to content
Browse files

initial commit

  • Loading branch information...
0 parents commit c31b499252e35af133d0699b2435604da84eb2bb @maxim committed
Showing with 1,742 additions and 0 deletions.
  1. +4 −0 .gitignore
  2. +89 −0 README.rdoc
  3. +14 −0 Rakefile
  4. +1 −0 init.rb
  5. +32 −0 lib/constrainer.rb
  6. +107 −0 lib/constraints.rb
  7. +21 −0 lib/deconstrainer.rb
  8. +17 −0 lib/helpers.rb
  9. +24 −0 lib/sexy_pg_constraints.rb
  10. +1,065 −0 test/postgresql_adapter.rb
  11. +363 −0 test/sexy_pg_constraints_test.rb
  12. +5 −0 test/test_helper.rb
4 .gitignore
@@ -0,0 +1,4 @@
+.DS_Store
+pkg
+doc
+Manifest
89 README.rdoc
@@ -0,0 +1,89 @@
+= Sexy PG Constraints
+
+If you're on PostgreSQL and see the importance of data-layer constraints - this gem/plugin is for you. It integrates constraints into PostgreSQL adapter so you can add/remove them in your migrations. You get two simple methods for adding/removing constraints, as well as a pack of pre-made constraints.
+
+== Install
+
+ gem install maxim-spc --source http://gems.github.com
+
+== Usage
+
+Say you have a table "books" and you want your Postgres DB to ensure that their title is not-blank, alphanumeric, and its length is between 3 and 50 chars. You also want to make sure that their isbn is unique. In addition you want to blacklist a few isbn numbers from ever being in your database. You can tell all that to your Postgres in no time. Generate a migration and write the following.
+
+ class AddConstraintsToBooks < ActiveRecord::Migration
+ def self.up
+ constrain :books do |t|
+ t.title :not_blank => true, :alphanumeric => true, :length_within => 3..50
+ t.isbn :unique => true, :blacklist => %w(badbook1 badbook2)
+ end
+ end
+
+ def self.down
+ deconstrain :books do |t|
+ t.title :not_blank, :alphanumeric, :length_within
+ t.isbn :unique, :blacklist
+ end
+ end
+ end
+
+This will add all the necessary constraints to the database on the next migration, and remove them on rollback.
+
+There's also a syntax for when you don't need to work with multiple columns at once.
+
+ constrain :books, :title, :not_blank => true, :length_within => 3..50
+
+The above line works exactly the same as this block
+
+ constrain :books do |t|
+ t.title :not_blank => true, :length_within => 3..50
+ end
+
+Same applies to deconstrain.
+
+== Available constraints
+
+Below is the list of constraints available and tested so far.
+
+* whitelist
+* blacklist
+* not_blank
+* within
+* length_within
+* email
+* alphanumeric
+* positive
+* unique
+* exact_length
+
+== Extensibility
+
+All constraints are located in the lib/constraints.rb. Extending this module with more methods will automatically make constraints available in migrations. All methods in the Constraints module are under module_function directive. Each method is supposed to return a piece of SQL that is inserted "alter table foo add constraint bar #{RIGHT HERE};."
+
+== TODO
+
+* Add foreign key constraints
+* Create better API for adding constraints
+* Add constraints with functionality corresponding to ActiveRecord validates_* pack
+
+== License
+
+Copyright (c) 2008 Maxim Chernyak
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
14 Rakefile
@@ -0,0 +1,14 @@
+require 'rubygems'
+require 'rake'
+require 'echoe'
+
+Echoe.new('sexy_pg_constraints', '0.1.0') do |p|
+ p.description = "Use migrations and painless syntax to manage constraints in PostgreSQL DB."
+ p.url = "http://github.com/maxim/sexy_pg_constraints"
+ p.author = "Maxim Chernyak"
+ p.email = "max@bitsonnet.com"
+ p.ignore_pattern = ["tmp/*", "script/*"]
+ p.development_dependencies = []
+end
+
+Dir["#{File.dirname(__FILE__)}/tasks/*.rake"].sort.each { |ext| load ext }
1 init.rb
@@ -0,0 +1 @@
+require 'sexy_pg_constraints'
32 lib/constrainer.rb
@@ -0,0 +1,32 @@
+module SexyPgConstraints
+ class Constrainer
+ include SexyPgConstraints::Helpers
+
+ def initialize(table)
+ @table = table.to_s
+ end
+
+ def method_missing(column, constraints)
+ self.class.add_constraints(@table, column.to_s, constraints)
+ end
+
+ class << self
+ def add_constraints(table, column, constraints)
+ available_constraints = SexyPgConstraints::Constraints.methods - Object.methods
+ valid_constraints = constraints.reject { |k, v| !(available_constraints).include?(k.to_s) }
+ invalid_constraints = constraints.keys - valid_constraints.keys
+
+ if invalid_constraints.size > 0
+ raise "Invalid constraints specified: #{(invalid_constraints).join(',')}"
+ end
+
+ valid_constraints.each_pair do |type, options|
+ sql = "alter table #{table} add constraint #{make_title(table, column, type)} " +
+ SexyPgConstraints::Constraints.send(type, column.to_s, options) + ';'
+
+ execute sql
+ end
+ end
+ end
+ end
+end
107 lib/constraints.rb
@@ -0,0 +1,107 @@
+module SexyPgConstraints
+ module Constraints
+ module_function
+
+ ##
+ # Only allow listed values.
+ #
+ # Example:
+ # constrain :books, :variation, :whitelist => %w(hardcover softcover)
+ #
+ def whitelist(column, options)
+ "check (#{column} in (#{ options.collect{|v| "'#{v}'"}.join(',') }))"
+ end
+
+ ##
+ # Prohibit listed values.
+ #
+ # Example:
+ # constrain :books, :isbn, :blacklist => %w(invalid_isbn1 invalid_isbn2)
+ #
+ def blacklist(column, options)
+ "check (#{column} not in (#{ options.collect{|v| "'#{v}'"}.join(',') }))"
+ end
+
+ ##
+ # The value must have at least 1 non-space character.
+ #
+ # Example:
+ # constrain :books, :title, :not_blank => true
+ #
+ def not_blank(column, options)
+ "check ( length(trim(both from #{column})) > 0 )"
+ end
+
+ ##
+ # The numeric value must be within given range.
+ #
+ # Example:
+ # constrain :books, :year, :within => 1980..2008
+ # constrain :books, :year, :within => 1980...2009
+ # (the two lines above do the same thing)
+ #
+ def within(column, options)
+ "check (#{column} >= #{options.begin} and #{column} #{options.exclude_end? ? ' < ' : ' <= '} #{options.end})"
+ end
+
+ ##
+ # Check the length of strings/text to be within the range.
+ #
+ # Example:
+ # constrain :books, :author, :length_within => 4..50
+ #
+ def length_within(column, options)
+ within("length(#{column})", options)
+ end
+
+ ##
+ # Allow only valid email format.
+ #
+ # Example:
+ # constrain :books, :author, :email => true
+ #
+ def email(column, options)
+ "check (((#{column})::text ~ E'^([-a-z0-9]+)@([-a-z0-9]+[.]+[a-z]{2,4})$'::text))"
+ end
+
+ ##
+ # Allow only alphanumeric values.
+ #
+ # Example:
+ # constrain :books, :author, :alphanumeric => true
+ #
+ def alphanumeric(column, options)
+ "check (((#{column})::text ~* '^[a-z0-9]+$'::text))"
+ end
+
+ ##
+ # Allow only positive values.
+ #
+ # Example:
+ # constrain :books, :quantity, :positive => true
+ #
+ def positive(column, options)
+ "check (#{column} > 0)"
+ end
+
+ ##
+ # Make sure every entry in the column is unique.
+ #
+ # Example:
+ # constrain :books, :isbn, :unique => true
+ #
+ def unique(column, options)
+ "unique (#{column})"
+ end
+
+ ##
+ # Allow only text/strings of the exact length specified, no more, no less.
+ #
+ # Example:
+ # constrain :books, :hash, :exact_length => 32
+ #
+ def exact_length(column, options)
+ "check ( length(trim(both from #{column})) = #{options} )"
+ end
+ end
+end
21 lib/deconstrainer.rb
@@ -0,0 +1,21 @@
+module SexyPgConstraints
+ class DeConstrainer
+ include SexyPgConstraints::Helpers
+
+ def initialize(table)
+ @table = table.to_s
+ end
+
+ def method_missing(column, *constraints)
+ self.class.drop_constraints(@table, column.to_s, *constraints)
+ end
+
+ class << self
+ def drop_constraints(table, column, *constraints)
+ constraints.each do |type|
+ execute "alter table #{table} drop constraint #{make_title(table, column, type)};"
+ end
+ end
+ end
+ end
+end
17 lib/helpers.rb
@@ -0,0 +1,17 @@
+module SexyPgConstraints
+ module Helpers
+ def self.included(base)
+ base.extend ClassMethods
+ end
+
+ module ClassMethods
+ def make_title(table, column, type)
+ "#{table}_#{column}_#{type}"
+ end
+
+ def execute(*args)
+ ActiveRecord::Base.connection.execute(*args)
+ end
+ end
+ end
+end
24 lib/sexy_pg_constraints.rb
@@ -0,0 +1,24 @@
+require "helpers"
+require "constrainer"
+require "deconstrainer"
+require "constraints"
+
+module SexyPgConstraints
+ def constrain(*args)
+ if block_given?
+ yield SexyPgConstraints::Constrainer.new(args[0].to_s)
+ else
+ SexyPgConstraints::Constrainer::add_constraints(*args)
+ end
+ end
+
+ def deconstrain(*args)
+ if block_given?
+ yield SexyPgConstraints::DeConstrainer.new(args[0])
+ else
+ SexyPgConstraints::DeConstrainer::drop_constraints(*args)
+ end
+ end
+end
+
+ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.send(:include, SexyPgConstraints)
1,065 test/postgresql_adapter.rb
@@ -0,0 +1,1065 @@
+require 'active_record/connection_adapters/abstract_adapter'
+
+begin
+ require_library_or_gem 'pg'
+rescue LoadError => e
+ begin
+ require_library_or_gem 'postgres'
+ class PGresult
+ alias_method :nfields, :num_fields unless self.method_defined?(:nfields)
+ alias_method :ntuples, :num_tuples unless self.method_defined?(:ntuples)
+ alias_method :ftype, :type unless self.method_defined?(:ftype)
+ alias_method :cmd_tuples, :cmdtuples unless self.method_defined?(:cmd_tuples)
+ end
+ rescue LoadError
+ raise e
+ end
+end
+
+module ActiveRecord
+ class Base
+ # Establishes a connection to the database that's used by all Active Record objects
+ def self.postgresql_connection(config) # :nodoc:
+ config = config.symbolize_keys
+ host = config[:host]
+ port = config[:port] || 5432
+ username = config[:username].to_s if config[:username]
+ password = config[:password].to_s if config[:password]
+
+ if config.has_key?(:database)
+ database = config[:database]
+ else
+ raise ArgumentError, "No database specified. Missing argument: database."
+ end
+
+ # The postgres drivers don't allow the creation of an unconnected PGconn object,
+ # so just pass a nil connection object for the time being.
+ ConnectionAdapters::PostgreSQLAdapter.new(nil, logger, [host, port, nil, nil, database, username, password], config)
+ end
+ end
+
+ module ConnectionAdapters
+ # PostgreSQL-specific extensions to column definitions in a table.
+ class PostgreSQLColumn < Column #:nodoc:
+ # Instantiates a new PostgreSQL column definition in a table.
+ def initialize(name, default, sql_type = nil, null = true)
+ super(name, self.class.extract_value_from_default(default), sql_type, null)
+ end
+
+ private
+ def extract_limit(sql_type)
+ case sql_type
+ when /^bigint/i; 8
+ when /^smallint/i; 2
+ else super
+ end
+ end
+
+ # Extracts the scale from PostgreSQL-specific data types.
+ def extract_scale(sql_type)
+ # Money type has a fixed scale of 2.
+ sql_type =~ /^money/ ? 2 : super
+ end
+
+ # Extracts the precision from PostgreSQL-specific data types.
+ def extract_precision(sql_type)
+ # Actual code is defined dynamically in PostgreSQLAdapter.connect
+ # depending on the server specifics
+ super
+ end
+
+ # Maps PostgreSQL-specific data types to logical Rails types.
+ def simplified_type(field_type)
+ case field_type
+ # Numeric and monetary types
+ when /^(?:real|double precision)$/
+ :float
+ # Monetary types
+ when /^money$/
+ :decimal
+ # Character types
+ when /^(?:character varying|bpchar)(?:\(\d+\))?$/
+ :string
+ # Binary data types
+ when /^bytea$/
+ :binary
+ # Date/time types
+ when /^timestamp with(?:out)? time zone$/
+ :datetime
+ when /^interval$/
+ :string
+ # Geometric types
+ when /^(?:point|line|lseg|box|"?path"?|polygon|circle)$/
+ :string
+ # Network address types
+ when /^(?:cidr|inet|macaddr)$/
+ :string
+ # Bit strings
+ when /^bit(?: varying)?(?:\(\d+\))?$/
+ :string
+ # XML type
+ when /^xml$/
+ :string
+ # Arrays
+ when /^\D+\[\]$/
+ :string
+ # Object identifier types
+ when /^oid$/
+ :integer
+ # Pass through all types that are not specific to PostgreSQL.
+ else
+ super
+ end
+ end
+
+ # Extracts the value from a PostgreSQL column default definition.
+ def self.extract_value_from_default(default)
+ case default
+ # Numeric types
+ when /\A\(?(-?\d+(\.\d*)?\)?)\z/
+ $1
+ # Character types
+ when /\A'(.*)'::(?:character varying|bpchar|text)\z/m
+ $1
+ # Character types (8.1 formatting)
+ when /\AE'(.*)'::(?:character varying|bpchar|text)\z/m
+ $1.gsub(/\\(\d\d\d)/) { $1.oct.chr }
+ # Binary data types
+ when /\A'(.*)'::bytea\z/m
+ $1
+ # Date/time types
+ when /\A'(.+)'::(?:time(?:stamp)? with(?:out)? time zone|date)\z/
+ $1
+ when /\A'(.*)'::interval\z/
+ $1
+ # Boolean type
+ when 'true'
+ true
+ when 'false'
+ false
+ # Geometric types
+ when /\A'(.*)'::(?:point|line|lseg|box|"?path"?|polygon|circle)\z/
+ $1
+ # Network address types
+ when /\A'(.*)'::(?:cidr|inet|macaddr)\z/
+ $1
+ # Bit string types
+ when /\AB'(.*)'::"?bit(?: varying)?"?\z/
+ $1
+ # XML type
+ when /\A'(.*)'::xml\z/m
+ $1
+ # Arrays
+ when /\A'(.*)'::"?\D+"?\[\]\z/
+ $1
+ # Object identifier types
+ when /\A-?\d+\z/
+ $1
+ else
+ # Anything else is blank, some user type, or some function
+ # and we can't know the value of that, so return nil.
+ nil
+ end
+ end
+ end
+ end
+
+ module ConnectionAdapters
+ # The PostgreSQL adapter works both with the native C (http://ruby.scripting.ca/postgres/) and the pure
+ # Ruby (available both as gem and from http://rubyforge.org/frs/?group_id=234&release_id=1944) drivers.
+ #
+ # Options:
+ #
+ # * <tt>:host</tt> - Defaults to "localhost".
+ # * <tt>:port</tt> - Defaults to 5432.
+ # * <tt>:username</tt> - Defaults to nothing.
+ # * <tt>:password</tt> - Defaults to nothing.
+ # * <tt>:database</tt> - The name of the database. No default, must be provided.
+ # * <tt>:schema_search_path</tt> - An optional schema search path for the connection given as a string of comma-separated schema names. This is backward-compatible with the <tt>:schema_order</tt> option.
+ # * <tt>:encoding</tt> - An optional client encoding that is used in a <tt>SET client_encoding TO <encoding></tt> call on the connection.
+ # * <tt>:min_messages</tt> - An optional client min messages that is used in a <tt>SET client_min_messages TO <min_messages></tt> call on the connection.
+ # * <tt>:allow_concurrency</tt> - If true, use async query methods so Ruby threads don't deadlock; otherwise, use blocking query methods.
+ class PostgreSQLAdapter < AbstractAdapter
+ ADAPTER_NAME = 'PostgreSQL'.freeze
+
+ NATIVE_DATABASE_TYPES = {
+ :primary_key => "serial primary key".freeze,
+ :string => { :name => "character varying", :limit => 255 },
+ :text => { :name => "text" },
+ :integer => { :name => "integer" },
+ :float => { :name => "float" },
+ :decimal => { :name => "decimal" },
+ :datetime => { :name => "timestamp" },
+ :timestamp => { :name => "timestamp" },
+ :time => { :name => "time" },
+ :date => { :name => "date" },
+ :binary => { :name => "bytea" },
+ :boolean => { :name => "boolean" }
+ }
+
+ # Returns 'PostgreSQL' as adapter name for identification purposes.
+ def adapter_name
+ ADAPTER_NAME
+ end
+
+ # Initializes and connects a PostgreSQL adapter.
+ def initialize(connection, logger, connection_parameters, config)
+ super(connection, logger)
+ @connection_parameters, @config = connection_parameters, config
+
+ connect
+ end
+
+ # Is this connection alive and ready for queries?
+ def active?
+ if @connection.respond_to?(:status)
+ @connection.status == PGconn::CONNECTION_OK
+ else
+ # We're asking the driver, not ActiveRecord, so use @connection.query instead of #query
+ @connection.query 'SELECT 1'
+ true
+ end
+ # postgres-pr raises a NoMethodError when querying if no connection is available.
+ rescue PGError, NoMethodError
+ false
+ end
+
+ # Close then reopen the connection.
+ def reconnect!
+ if @connection.respond_to?(:reset)
+ @connection.reset
+ configure_connection
+ else
+ disconnect!
+ connect
+ end
+ end
+
+ # Close the connection.
+ def disconnect!
+ @connection.close rescue nil
+ end
+
+ def native_database_types #:nodoc:
+ NATIVE_DATABASE_TYPES
+ end
+
+ # Does PostgreSQL support migrations?
+ def supports_migrations?
+ true
+ end
+
+ # Does PostgreSQL support standard conforming strings?
+ def supports_standard_conforming_strings?
+ # Temporarily set the client message level above error to prevent unintentional
+ # error messages in the logs when working on a PostgreSQL database server that
+ # does not support standard conforming strings.
+ client_min_messages_old = client_min_messages
+ self.client_min_messages = 'panic'
+
+ # postgres-pr does not raise an exception when client_min_messages is set higher
+ # than error and "SHOW standard_conforming_strings" fails, but returns an empty
+ # PGresult instead.
+ has_support = query('SHOW standard_conforming_strings')[0][0] rescue false
+ self.client_min_messages = client_min_messages_old
+ has_support
+ end
+
+ def supports_insert_with_returning?
+ postgresql_version >= 80200
+ end
+
+ def supports_ddl_transactions?
+ true
+ end
+
+ # Returns the configured supported identifier length supported by PostgreSQL,
+ # or report the default of 63 on PostgreSQL 7.x.
+ def table_alias_length
+ @table_alias_length ||= (postgresql_version >= 80000 ? query('SHOW max_identifier_length')[0][0].to_i : 63)
+ end
+
+ # QUOTING ==================================================
+
+ # Escapes binary strings for bytea input to the database.
+ def escape_bytea(value)
+ if PGconn.respond_to?(:escape_bytea)
+ self.class.instance_eval do
+ define_method(:escape_bytea) do |value|
+ PGconn.escape_bytea(value) if value
+ end
+ end
+ else
+ self.class.instance_eval do
+ define_method(:escape_bytea) do |value|
+ if value
+ result = ''
+ value.each_byte { |c| result << sprintf('\\\\%03o', c) }
+ result
+ end
+ end
+ end
+ end
+ escape_bytea(value)
+ end
+
+ # Unescapes bytea output from a database to the binary string it represents.
+ # NOTE: This is NOT an inverse of escape_bytea! This is only to be used
+ # on escaped binary output from database drive.
+ def unescape_bytea(value)
+ # In each case, check if the value actually is escaped PostgreSQL bytea output
+ # or an unescaped Active Record attribute that was just written.
+ if PGconn.respond_to?(:unescape_bytea)
+ self.class.instance_eval do
+ define_method(:unescape_bytea) do |value|
+ if value =~ /\\\d{3}/
+ PGconn.unescape_bytea(value)
+ else
+ value
+ end
+ end
+ end
+ else
+ self.class.instance_eval do
+ define_method(:unescape_bytea) do |value|
+ if value =~ /\\\d{3}/
+ result = ''
+ i, max = 0, value.size
+ while i < max
+ char = value[i]
+ if char == ?\\
+ if value[i+1] == ?\\
+ char = ?\\
+ i += 1
+ else
+ char = value[i+1..i+3].oct
+ i += 3
+ end
+ end
+ result << char
+ i += 1
+ end
+ result
+ else
+ value
+ end
+ end
+ end
+ end
+ unescape_bytea(value)
+ end
+
+ # Quotes PostgreSQL-specific data types for SQL input.
+ def quote(value, column = nil) #:nodoc:
+ if value.kind_of?(String) && column && column.type == :binary
+ "#{quoted_string_prefix}'#{escape_bytea(value)}'"
+ elsif value.kind_of?(String) && column && column.sql_type =~ /^xml$/
+ "xml '#{quote_string(value)}'"
+ elsif value.kind_of?(Numeric) && column && column.sql_type =~ /^money$/
+ # Not truly string input, so doesn't require (or allow) escape string syntax.
+ "'#{value.to_s}'"
+ elsif value.kind_of?(String) && column && column.sql_type =~ /^bit/
+ case value
+ when /^[01]*$/
+ "B'#{value}'" # Bit-string notation
+ when /^[0-9A-F]*$/i
+ "X'#{value}'" # Hexadecimal notation
+ end
+ else
+ super
+ end
+ end
+
+ # Quotes strings for use in SQL input in the postgres driver for better performance.
+ def quote_string(s) #:nodoc:
+ if PGconn.respond_to?(:escape)
+ self.class.instance_eval do
+ define_method(:quote_string) do |s|
+ PGconn.escape(s)
+ end
+ end
+ else
+ # There are some incorrectly compiled postgres drivers out there
+ # that don't define PGconn.escape.
+ self.class.instance_eval do
+ remove_method(:quote_string)
+ end
+ end
+ quote_string(s)
+ end
+
+ # Quotes column names for use in SQL queries.
+ def quote_column_name(name) #:nodoc:
+ %("#{name}")
+ end
+
+ # Quote date/time values for use in SQL input. Includes microseconds
+ # if the value is a Time responding to usec.
+ def quoted_date(value) #:nodoc:
+ if value.acts_like?(:time) && value.respond_to?(:usec)
+ "#{super}.#{sprintf("%06d", value.usec)}"
+ else
+ super
+ end
+ end
+
+ # REFERENTIAL INTEGRITY ====================================
+
+ def supports_disable_referential_integrity?() #:nodoc:
+ version = query("SHOW server_version")[0][0].split('.')
+ (version[0].to_i >= 8 && version[1].to_i >= 1) ? true : false
+ rescue
+ return false
+ end
+
+ def disable_referential_integrity(&block) #:nodoc:
+ if supports_disable_referential_integrity?() then
+ execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} DISABLE TRIGGER ALL" }.join(";"))
+ end
+ yield
+ ensure
+ if supports_disable_referential_integrity?() then
+ execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} ENABLE TRIGGER ALL" }.join(";"))
+ end
+ end
+
+ # DATABASE STATEMENTS ======================================
+
+ # Executes a SELECT query and returns an array of rows. Each row is an
+ # array of field values.
+ def select_rows(sql, name = nil)
+ select_raw(sql, name).last
+ end
+
+ # Executes an INSERT query and returns the new record's ID
+ def insert(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil)
+ # Extract the table from the insert sql. Yuck.
+ table = sql.split(" ", 4)[2].gsub('"', '')
+
+ # Try an insert with 'returning id' if available (PG >= 8.2)
+ if supports_insert_with_returning?
+ pk, sequence_name = *pk_and_sequence_for(table) unless pk
+ if pk
+ id = select_value("#{sql} RETURNING #{quote_column_name(pk)}")
+ clear_query_cache
+ return id
+ end
+ end
+
+ # Otherwise, insert then grab last_insert_id.
+ if insert_id = super
+ insert_id
+ else
+ # If neither pk nor sequence name is given, look them up.
+ unless pk || sequence_name
+ pk, sequence_name = *pk_and_sequence_for(table)
+ end
+
+ # If a pk is given, fallback to default sequence name.
+ # Don't fetch last insert id for a table without a pk.
+ if pk && sequence_name ||= default_sequence_name(table, pk)
+ last_insert_id(table, sequence_name)
+ end
+ end
+ end
+
+ # create a 2D array representing the result set
+ def result_as_array(res) #:nodoc:
+ # check if we have any binary column and if they need escaping
+ unescape_col = []
+ for j in 0...res.nfields do
+ # unescape string passed BYTEA field (OID == 17)
+ unescape_col << ( res.ftype(j)==17 )
+ end
+
+ ary = []
+ for i in 0...res.ntuples do
+ ary << []
+ for j in 0...res.nfields do
+ data = res.getvalue(i,j)
+ data = unescape_bytea(data) if unescape_col[j] and data.is_a?(String)
+ ary[i] << data
+ end
+ end
+ return ary
+ end
+
+
+ # Queries the database and returns the results in an Array-like object
+ def query(sql, name = nil) #:nodoc:
+ log(sql, name) do
+ if @async
+ res = @connection.async_exec(sql)
+ else
+ res = @connection.exec(sql)
+ end
+ return result_as_array(res)
+ end
+ end
+
+ # Executes an SQL statement, returning a PGresult object on success
+ # or raising a PGError exception otherwise.
+ def execute(sql, name = nil)
+ log(sql, name) do
+ if @async
+ @connection.async_exec(sql)
+ else
+ @connection.exec(sql)
+ end
+ end
+ end
+
+ # Executes an UPDATE query and returns the number of affected tuples.
+ def update_sql(sql, name = nil)
+ super.cmd_tuples
+ end
+
+ # Begins a transaction.
+ def begin_db_transaction
+ execute "BEGIN"
+ end
+
+ # Commits a transaction.
+ def commit_db_transaction
+ execute "COMMIT"
+ end
+
+ # Aborts a transaction.
+ def rollback_db_transaction
+ execute "ROLLBACK"
+ end
+
+ # ruby-pg defines Ruby constants for transaction status,
+ # ruby-postgres does not.
+ PQTRANS_IDLE = defined?(PGconn::PQTRANS_IDLE) ? PGconn::PQTRANS_IDLE : 0
+
+ # Check whether a transaction is active.
+ def transaction_active?
+ @connection.transaction_status != PQTRANS_IDLE
+ end
+
+ # Wrap a block in a transaction. Returns result of block.
+ def transaction(start_db_transaction = true)
+ transaction_open = false
+ begin
+ if block_given?
+ if start_db_transaction
+ begin_db_transaction
+ transaction_open = true
+ end
+ yield
+ end
+ rescue Exception => database_transaction_rollback
+ if transaction_open && transaction_active?
+ transaction_open = false
+ rollback_db_transaction
+ end
+ raise unless database_transaction_rollback.is_a? ActiveRecord::Rollback
+ end
+ ensure
+ if transaction_open && transaction_active?
+ begin
+ commit_db_transaction
+ rescue Exception => database_transaction_rollback
+ rollback_db_transaction
+ raise
+ end
+ end
+ end
+
+
+ # SCHEMA STATEMENTS ========================================
+
+ def recreate_database(name) #:nodoc:
+ drop_database(name)
+ create_database(name)
+ end
+
+ # Create a new PostgreSQL database. Options include <tt>:owner</tt>, <tt>:template</tt>,
+ # <tt>:encoding</tt>, <tt>:tablespace</tt>, and <tt>:connection_limit</tt> (note that MySQL uses
+ # <tt>:charset</tt> while PostgreSQL uses <tt>:encoding</tt>).
+ #
+ # Example:
+ # create_database config[:database], config
+ # create_database 'foo_development', :encoding => 'unicode'
+ def create_database(name, options = {})
+ options = options.reverse_merge(:encoding => "utf8")
+
+ option_string = options.symbolize_keys.sum do |key, value|
+ case key
+ when :owner
+ " OWNER = \"#{value}\""
+ when :template
+ " TEMPLATE = \"#{value}\""
+ when :encoding
+ " ENCODING = '#{value}'"
+ when :tablespace
+ " TABLESPACE = \"#{value}\""
+ when :connection_limit
+ " CONNECTION LIMIT = #{value}"
+ else
+ ""
+ end
+ end
+
+ execute "CREATE DATABASE #{quote_table_name(name)}#{option_string}"
+ end
+
+ # Drops a PostgreSQL database
+ #
+ # Example:
+ # drop_database 'matt_development'
+ def drop_database(name) #:nodoc:
+ if postgresql_version >= 80200
+ execute "DROP DATABASE IF EXISTS #{quote_table_name(name)}"
+ else
+ begin
+ execute "DROP DATABASE #{quote_table_name(name)}"
+ rescue ActiveRecord::StatementInvalid
+ @logger.warn "#{name} database doesn't exist." if @logger
+ end
+ end
+ end
+
+
+ # Returns the list of all tables in the schema search path or a specified schema.
+ def tables(name = nil)
+ schemas = schema_search_path.split(/,/).map { |p| quote(p) }.join(',')
+ query(<<-SQL, name).map { |row| row[0] }
+ SELECT tablename
+ FROM pg_tables
+ WHERE schemaname IN (#{schemas})
+ SQL
+ end
+
+ # Returns the list of all indexes for a table.
+ def indexes(table_name, name = nil)
+ schemas = schema_search_path.split(/,/).map { |p| quote(p) }.join(',')
+ result = query(<<-SQL, name)
+ SELECT distinct i.relname, d.indisunique, a.attname
+ FROM pg_class t, pg_class i, pg_index d, pg_attribute a
+ WHERE i.relkind = 'i'
+ AND d.indexrelid = i.oid
+ AND d.indisprimary = 'f'
+ AND t.oid = d.indrelid
+ AND t.relname = '#{table_name}'
+ AND i.relnamespace IN (SELECT oid FROM pg_namespace WHERE nspname IN (#{schemas}) )
+ AND a.attrelid = t.oid
+ AND ( d.indkey[0]=a.attnum OR d.indkey[1]=a.attnum
+ OR d.indkey[2]=a.attnum OR d.indkey[3]=a.attnum
+ OR d.indkey[4]=a.attnum OR d.indkey[5]=a.attnum
+ OR d.indkey[6]=a.attnum OR d.indkey[7]=a.attnum
+ OR d.indkey[8]=a.attnum OR d.indkey[9]=a.attnum )
+ ORDER BY i.relname
+ SQL
+
+ current_index = nil
+ indexes = []
+
+ result.each do |row|
+ if current_index != row[0]
+ indexes << IndexDefinition.new(table_name, row[0], row[1] == "t", [])
+ current_index = row[0]
+ end
+
+ indexes.last.columns << row[2]
+ end
+
+ indexes
+ end
+
+ # Returns the list of all column definitions for a table.
+ def columns(table_name, name = nil)
+ # Limit, precision, and scale are all handled by the superclass.
+ column_definitions(table_name).collect do |name, type, default, notnull|
+ PostgreSQLColumn.new(name, default, type, notnull == 'f')
+ end
+ end
+
+ # Returns the current database name.
+ def current_database
+ query('select current_database()')[0][0]
+ end
+
+ # Returns the current database encoding format.
+ def encoding
+ query(<<-end_sql)[0][0]
+ SELECT pg_encoding_to_char(pg_database.encoding) FROM pg_database
+ WHERE pg_database.datname LIKE '#{current_database}'
+ end_sql
+ end
+
+ # Sets the schema search path to a string of comma-separated schema names.
+ # Names beginning with $ have to be quoted (e.g. $user => '$user').
+ # See: http://www.postgresql.org/docs/current/static/ddl-schemas.html
+ #
+ # This should be not be called manually but set in database.yml.
+ def schema_search_path=(schema_csv)
+ if schema_csv
+ execute "SET search_path TO #{schema_csv}"
+ @schema_search_path = schema_csv
+ end
+ end
+
+ # Returns the active schema search path.
+ def schema_search_path
+ @schema_search_path ||= query('SHOW search_path')[0][0]
+ end
+
+ # Returns the current client message level.
+ def client_min_messages
+ query('SHOW client_min_messages')[0][0]
+ end
+
+ # Set the client message level.
+ def client_min_messages=(level)
+ execute("SET client_min_messages TO '#{level}'")
+ end
+
+ # Returns the sequence name for a table's primary key or some other specified key.
+ def default_sequence_name(table_name, pk = nil) #:nodoc:
+ default_pk, default_seq = pk_and_sequence_for(table_name)
+ default_seq || "#{table_name}_#{pk || default_pk || 'id'}_seq"
+ end
+
+ # Resets the sequence of a table's primary key to the maximum value.
+ def reset_pk_sequence!(table, pk = nil, sequence = nil) #:nodoc:
+ unless pk and sequence
+ default_pk, default_sequence = pk_and_sequence_for(table)
+ pk ||= default_pk
+ sequence ||= default_sequence
+ end
+ if pk
+ if sequence
+ quoted_sequence = quote_column_name(sequence)
+
+ select_value <<-end_sql, 'Reset sequence'
+ SELECT setval('#{quoted_sequence}', (SELECT COALESCE(MAX(#{quote_column_name pk})+(SELECT increment_by FROM #{quoted_sequence}), (SELECT min_value FROM #{quoted_sequence})) FROM #{quote_table_name(table)}), false)
+ end_sql
+ else
+ @logger.warn "#{table} has primary key #{pk} with no default sequence" if @logger
+ end
+ end
+ end
+
+ # Returns a table's primary key and belonging sequence.
+ def pk_and_sequence_for(table) #:nodoc:
+ # First try looking for a sequence with a dependency on the
+ # given table's primary key.
+ result = query(<<-end_sql, 'PK and serial sequence')[0]
+ SELECT attr.attname, seq.relname
+ FROM pg_class seq,
+ pg_attribute attr,
+ pg_depend dep,
+ pg_namespace name,
+ pg_constraint cons
+ WHERE seq.oid = dep.objid
+ AND seq.relkind = 'S'
+ AND attr.attrelid = dep.refobjid
+ AND attr.attnum = dep.refobjsubid
+ AND attr.attrelid = cons.conrelid
+ AND attr.attnum = cons.conkey[1]
+ AND cons.contype = 'p'
+ AND dep.refobjid = '#{table}'::regclass
+ end_sql
+
+ if result.nil? or result.empty?
+ # If that fails, try parsing the primary key's default value.
+ # Support the 7.x and 8.0 nextval('foo'::text) as well as
+ # the 8.1+ nextval('foo'::regclass).
+ result = query(<<-end_sql, 'PK and custom sequence')[0]
+ SELECT attr.attname,
+ CASE
+ WHEN split_part(def.adsrc, '''', 2) ~ '.' THEN
+ substr(split_part(def.adsrc, '''', 2),
+ strpos(split_part(def.adsrc, '''', 2), '.')+1)
+ ELSE split_part(def.adsrc, '''', 2)
+ END
+ FROM pg_class t
+ JOIN pg_attribute attr ON (t.oid = attrelid)
+ JOIN pg_attrdef def ON (adrelid = attrelid AND adnum = attnum)
+ JOIN pg_constraint cons ON (conrelid = adrelid AND adnum = conkey[1])
+ WHERE t.oid = '#{table}'::regclass
+ AND cons.contype = 'p'
+ AND def.adsrc ~* 'nextval'
+ end_sql
+ end
+
+ # [primary_key, sequence]
+ [result.first, result.last]
+ rescue
+ nil
+ end
+
+ # Renames a table.
+ def rename_table(name, new_name)
+ execute "ALTER TABLE #{quote_table_name(name)} RENAME TO #{quote_table_name(new_name)}"
+ end
+
+ # Adds a new column to the named table.
+ # See TableDefinition#column for details of the options you can use.
+ def add_column(table_name, column_name, type, options = {})
+ default = options[:default]
+ notnull = options[:null] == false
+
+ # Add the column.
+ execute("ALTER TABLE #{quote_table_name(table_name)} ADD COLUMN #{quote_column_name(column_name)} #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}")
+
+ change_column_default(table_name, column_name, default) if options_include_default?(options)
+ change_column_null(table_name, column_name, false, default) if notnull
+ end
+
+ # Changes the column of a table.
+ def change_column(table_name, column_name, type, options = {})
+ quoted_table_name = quote_table_name(table_name)
+
+ begin
+ execute "ALTER TABLE #{quoted_table_name} ALTER COLUMN #{quote_column_name(column_name)} TYPE #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}"
+ rescue ActiveRecord::StatementInvalid => e
+ raise e if postgresql_version > 80000
+ # This is PostgreSQL 7.x, so we have to use a more arcane way of doing it.
+ begin
+ begin_db_transaction
+ tmp_column_name = "#{column_name}_ar_tmp"
+ add_column(table_name, tmp_column_name, type, options)
+ execute "UPDATE #{quoted_table_name} SET #{quote_column_name(tmp_column_name)} = CAST(#{quote_column_name(column_name)} AS #{type_to_sql(type, options[:limit], options[:precision], options[:scale])})"
+ remove_column(table_name, column_name)
+ rename_column(table_name, tmp_column_name, column_name)
+ commit_db_transaction
+ rescue
+ rollback_db_transaction
+ end
+ end
+
+ change_column_default(table_name, column_name, options[:default]) if options_include_default?(options)
+ change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null)
+ end
+
+ # Changes the default value of a table column.
+ def change_column_default(table_name, column_name, default)
+ execute "ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} SET DEFAULT #{quote(default)}"
+ end
+
+ def change_column_null(table_name, column_name, null, default = nil)
+ unless null || default.nil?
+ execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote(default)} WHERE #{quote_column_name(column_name)} IS NULL")
+ end
+ execute("ALTER TABLE #{quote_table_name(table_name)} ALTER #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL")
+ end
+
+ # Renames a column in a table.
+ def rename_column(table_name, column_name, new_column_name)
+ execute "ALTER TABLE #{quote_table_name(table_name)} RENAME COLUMN #{quote_column_name(column_name)} TO #{quote_column_name(new_column_name)}"
+ end
+
+ # Drops an index from a table.
+ def remove_index(table_name, options = {})
+ execute "DROP INDEX #{index_name(table_name, options)}"
+ end
+
+ # Maps logical Rails types to PostgreSQL-specific data types.
+ def type_to_sql(type, limit = nil, precision = nil, scale = nil)
+ return super unless type.to_s == 'integer'
+
+ case limit
+ when 1..2; 'smallint'
+ when 3..4, nil; 'integer'
+ when 5..8; 'bigint'
+ else raise(ActiveRecordError, "No integer type has byte size #{limit}. Use a numeric with precision 0 instead.")
+ end
+ end
+
+ # Returns a SELECT DISTINCT clause for a given set of columns and a given ORDER BY clause.
+ #
+ # PostgreSQL requires the ORDER BY columns in the select list for distinct queries, and
+ # requires that the ORDER BY include the distinct column.
+ #
+ # distinct("posts.id", "posts.created_at desc")
+ def distinct(columns, order_by) #:nodoc:
+ return "DISTINCT #{columns}" if order_by.blank?
+
+ # Construct a clean list of column names from the ORDER BY clause, removing
+ # any ASC/DESC modifiers
+ order_columns = order_by.split(',').collect { |s| s.split.first }
+ order_columns.delete_if &:blank?
+ order_columns = order_columns.zip((0...order_columns.size).to_a).map { |s,i| "#{s} AS alias_#{i}" }
+
+ # Return a DISTINCT ON() clause that's distinct on the columns we want but includes
+ # all the required columns for the ORDER BY to work properly.
+ sql = "DISTINCT ON (#{columns}) #{columns}, "
+ sql << order_columns * ', '
+ end
+
+ # Returns an ORDER BY clause for the passed order option.
+ #
+ # PostgreSQL does not allow arbitrary ordering when using DISTINCT ON, so we work around this
+ # by wrapping the +sql+ string as a sub-select and ordering in that query.
+ def add_order_by_for_association_limiting!(sql, options) #:nodoc:
+ return sql if options[:order].blank?
+
+ order = options[:order].split(',').collect { |s| s.strip }.reject(&:blank?)
+ order.map! { |s| 'DESC' if s =~ /\bdesc$/i }
+ order = order.zip((0...order.size).to_a).map { |s,i| "id_list.alias_#{i} #{s}" }.join(', ')
+
+ sql.replace "SELECT * FROM (#{sql}) AS id_list ORDER BY #{order}"
+ end
+
+ protected
+ # Returns the version of the connected PostgreSQL version.
+ def postgresql_version
+ @postgresql_version ||=
+ if @connection.respond_to?(:server_version)
+ @connection.server_version
+ else
+ # Mimic PGconn.server_version behavior
+ begin
+ query('SELECT version()')[0][0] =~ /PostgreSQL (\d+)\.(\d+)\.(\d+)/
+ ($1.to_i * 10000) + ($2.to_i * 100) + $3.to_i
+ rescue
+ 0
+ end
+ end
+ end
+
+ private
+ # The internal PostgreSQL identifier of the money data type.
+ MONEY_COLUMN_TYPE_OID = 790 #:nodoc:
+
+ # Connects to a PostgreSQL server and sets up the adapter depending on the
+ # connected server's characteristics.
+ def connect
+ @connection = PGconn.connect(*@connection_parameters)
+ PGconn.translate_results = false if PGconn.respond_to?(:translate_results=)
+
+ # Ignore async_exec and async_query when using postgres-pr.
+ @async = @config[:allow_concurrency] && @connection.respond_to?(:async_exec)
+
+ # Use escape string syntax if available. We cannot do this lazily when encountering
+ # the first string, because that could then break any transactions in progress.
+ # See: http://www.postgresql.org/docs/current/static/runtime-config-compatible.html
+ # If PostgreSQL doesn't know the standard_conforming_strings parameter then it doesn't
+ # support escape string syntax. Don't override the inherited quoted_string_prefix.
+ if supports_standard_conforming_strings?
+ self.class.instance_eval do
+ define_method(:quoted_string_prefix) { 'E' }
+ end
+ end
+
+ # Money type has a fixed precision of 10 in PostgreSQL 8.2 and below, and as of
+ # PostgreSQL 8.3 it has a fixed precision of 19. PostgreSQLColumn.extract_precision
+ # should know about this but can't detect it there, so deal with it here.
+ money_precision = (postgresql_version >= 80300) ? 19 : 10
+ PostgreSQLColumn.module_eval(<<-end_eval)
+ def extract_precision(sql_type)
+ if sql_type =~ /^money$/
+ #{money_precision}
+ else
+ super
+ end
+ end
+ end_eval
+
+ configure_connection
+ end
+
+ # Configures the encoding, verbosity, and schema search path of the connection.
+ # This is called by #connect and should not be called manually.
+ def configure_connection
+ if @config[:encoding]
+ if @connection.respond_to?(:set_client_encoding)
+ @connection.set_client_encoding(@config[:encoding])
+ else
+ execute("SET client_encoding TO '#{@config[:encoding]}'")
+ end
+ end
+ self.client_min_messages = @config[:min_messages] if @config[:min_messages]
+ self.schema_search_path = @config[:schema_search_path] || @config[:schema_order]
+ end
+
+ # Returns the current ID of a table's sequence.
+ def last_insert_id(table, sequence_name) #:nodoc:
+ Integer(select_value("SELECT currval('#{sequence_name}')"))
+ end
+
+ # Executes a SELECT query and returns the results, performing any data type
+ # conversions that are required to be performed here instead of in PostgreSQLColumn.
+ def select(sql, name = nil)
+ fields, rows = select_raw(sql, name)
+ result = []
+ for row in rows
+ row_hash = {}
+ fields.each_with_index do |f, i|
+ row_hash[f] = row[i]
+ end
+ result << row_hash
+ end
+ result
+ end
+
+ def select_raw(sql, name = nil)
+ res = execute(sql, name)
+ results = result_as_array(res)
+ fields = []
+ rows = []
+ if res.ntuples > 0
+ fields = res.fields
+ results.each do |row|
+ hashed_row = {}
+ row.each_index do |cell_index|
+ # If this is a money type column and there are any currency symbols,
+ # then strip them off. Indeed it would be prettier to do this in
+ # PostgreSQLColumn.string_to_decimal but would break form input
+ # fields that call value_before_type_cast.
+ if res.ftype(cell_index) == MONEY_COLUMN_TYPE_OID
+ # Because money output is formatted according to the locale, there are two
+ # cases to consider (note the decimal separators):
+ # (1) $12,345,678.12
+ # (2) $12.345.678,12
+ case column = row[cell_index]
+ when /^-?\D+[\d,]+\.\d{2}$/ # (1)
+ row[cell_index] = column.gsub(/[^-\d\.]/, '')
+ when /^-?\D+[\d\.]+,\d{2}$/ # (2)
+ row[cell_index] = column.gsub(/[^-\d,]/, '').sub(/,/, '.')
+ end
+ end
+
+ hashed_row[fields[cell_index]] = column
+ end
+ rows << row
+ end
+ end
+ res.clear
+ return fields, rows
+ end
+
+ # Returns the list of a table's column names, data types, and default values.
+ #
+ # The underlying query is roughly:
+ # SELECT column.name, column.type, default.value
+ # FROM column LEFT JOIN default
+ # ON column.table_id = default.table_id
+ # AND column.num = default.column_num
+ # WHERE column.table_id = get_table_id('table_name')
+ # AND column.num > 0
+ # AND NOT column.is_dropped
+ # ORDER BY column.num
+ #
+ # If the table name is not prefixed with a schema, the database will
+ # take the first match from the schema search path.
+ #
+ # Query implementation notes:
+ # - format_type includes the column size constraint, e.g. varchar(50)
+ # - ::regclass is a function that gives the id for a table name
+ def column_definitions(table_name) #:nodoc:
+ query <<-end_sql
+ SELECT a.attname, format_type(a.atttypid, a.atttypmod), d.adsrc, a.attnotnull
+ FROM pg_attribute a LEFT JOIN pg_attrdef d
+ ON a.attrelid = d.adrelid AND a.attnum = d.adnum
+ WHERE a.attrelid = '#{table_name}'::regclass
+ AND a.attnum > 0 AND NOT a.attisdropped
+ ORDER BY a.attnum
+ end_sql
+ end
+ end
+ end
+end
363 test/sexy_pg_constraints_test.rb
@@ -0,0 +1,363 @@
+require File.dirname(__FILE__) + '/test_helper.rb'
+
+# Database spc_test should be created manually.
+ActiveRecord::Base.establish_connection(:adapter => "postgresql", :database => "spc_test")
+
+# Setting sample up migrations
+class CreateBooks < ActiveRecord::Migration
+ def self.up
+ create_table :books do |t|
+ t.string :title
+ t.string :author
+ t.integer :quantity
+ t.string :isbn
+ end
+ end
+
+ def self.down
+ drop_table :books
+ end
+end
+
+class Book < ActiveRecord::Base; end
+
+class SexyPgConstraintsTest < Test::Unit::TestCase
+ def setup
+ CreateBooks.up
+ end
+
+ def teardown
+ CreateBooks.down
+ end
+
+ def test_should_create_book
+ Book.create
+ assert_equal Book.count, 1
+ end
+
+ def test_whitelist
+ ActiveRecord::Migration.constrain :books, :author, :whitelist => %w(whitelisted1 whitelisted2 whitelisted3)
+
+ assert_prohibits :author, :whitelist do |book|
+ book.author = 'not_whitelisted'
+ end
+
+ assert_allows do |book|
+ book.author = 'whitelisted2'
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :author, :whitelist
+
+ assert_allows do |book|
+ book.author = 'not_whitelisted'
+ end
+ end
+
+ def test_blacklist
+ ActiveRecord::Migration.constrain :books, :author, :blacklist => %w(blacklisted1 blacklisted2 blacklisted3)
+
+ assert_prohibits :author, :blacklist do |book|
+ book.author = 'blacklisted2'
+ end
+
+ assert_allows do |book|
+ book.author = 'not_blacklisted'
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :author, :blacklist
+
+ assert_allows do |book|
+ book.author = 'blacklisted2'
+ end
+ end
+
+ def test_not_blank
+ ActiveRecord::Migration.constrain :books, :author, :not_blank => true
+
+ assert_prohibits :author, :not_blank do |book|
+ book.author = ' '
+ end
+
+ assert_allows do |book|
+ book.author = 'foo'
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :author, :not_blank
+
+ assert_allows do |book|
+ book.author = ' '
+ end
+ end
+
+ def test_within_inclusive
+ ActiveRecord::Migration.constrain :books, :quantity, :within => 5..11
+
+ assert_prohibits :quantity, :within do |book|
+ book.quantity = 12
+ end
+
+ assert_prohibits :quantity, :within do |book|
+ book.quantity = 4
+ end
+
+ assert_allows do |book|
+ book.quantity = 7
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :quantity, :within
+
+ assert_allows do |book|
+ book.quantity = 12
+ end
+ end
+
+ def test_within_non_inclusive
+ ActiveRecord::Migration.constrain :books, :quantity, :within => 5...11
+
+ assert_prohibits :quantity, :within do |book|
+ book.quantity = 11
+ end
+
+ assert_prohibits :quantity, :within do |book|
+ book.quantity = 4
+ end
+
+ assert_allows do |book|
+ book.quantity = 10
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :quantity, :within
+
+ assert_allows do |book|
+ book.quantity = 11
+ end
+ end
+
+ def test_length_within_inclusive
+ ActiveRecord::Migration.constrain :books, :title, :length_within => 5..11
+
+ assert_prohibits :title, :length_within do |book|
+ book.title = 'abcdefghijkl'
+ end
+
+ assert_prohibits :title, :length_within do |book|
+ book.title = 'abcd'
+ end
+
+ assert_allows do |book|
+ book.title = 'abcdefg'
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :title, :length_within
+
+ assert_allows do |book|
+ book.title = 'abcdefghijkl'
+ end
+ end
+
+ def test_length_within_non_inclusive
+ ActiveRecord::Migration.constrain :books, :title, :length_within => 5...11
+
+ assert_prohibits :title, :length_within do |book|
+ book.title = 'abcdefghijk'
+ end
+
+ assert_prohibits :title, :length_within do |book|
+ book.title = 'abcd'
+ end
+
+ assert_allows do |book|
+ book.title = 'abcdefg'
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :title, :length_within
+
+ assert_allows do |book|
+ book.title = 'abcdefghijk'
+ end
+ end
+
+ def test_email
+ ActiveRecord::Migration.constrain :books, :author, :email => true
+
+ assert_prohibits :author, :email do |book|
+ book.author = 'blah@example'
+ end
+
+ assert_allows do |book|
+ book.author = 'blah@example.com'
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :author, :email
+
+ assert_allows do |book|
+ book.author = 'blah@example'
+ end
+ end
+
+ def test_alphanumeric
+ ActiveRecord::Migration.constrain :books, :title, :alphanumeric => true
+
+ assert_prohibits :title, :alphanumeric do |book|
+ book.title = 'asdf@asdf'
+ end
+
+ assert_allows do |book|
+ book.title = 'asdf'
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :title, :alphanumeric
+
+ assert_allows do |book|
+ book.title = 'asdf@asdf'
+ end
+ end
+
+ def test_positive
+ ActiveRecord::Migration.constrain :books, :quantity, :positive => true
+
+ assert_prohibits :quantity, :positive do |book|
+ book.quantity = -1
+ end
+
+ assert_allows do |book|
+ book.quantity = 1
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :quantity, :positive
+
+ assert_allows do |book|
+ book.quantity = -1
+ end
+ end
+
+ def test_unique
+ ActiveRecord::Migration.constrain :books, :isbn, :unique => true
+
+ assert_allows do |book|
+ book.isbn = 'foo'
+ end
+
+ assert_prohibits :isbn, :unique, 'unique' do |book|
+ book.isbn = 'foo'
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :isbn, :unique
+
+ assert_allows do |book|
+ book.isbn = 'foo'
+ end
+ end
+
+ def test_exact_length
+ ActiveRecord::Migration.constrain :books, :isbn, :exact_length => 5
+
+ assert_prohibits :isbn, :exact_length do |book|
+ book.isbn = '123456'
+ end
+
+ assert_prohibits :isbn, :exact_length do |book|
+ book.isbn = '1234'
+ end
+
+ assert_allows do |book|
+ book.isbn = '12345'
+ end
+
+ ActiveRecord::Migration.deconstrain :books, :isbn, :exact_length
+
+ assert_allows do |book|
+ book.isbn = '123456'
+ end
+ end
+
+
+ def test_block_syntax
+ ActiveRecord::Migration.constrain :books do |t|
+ t.title :not_blank => true
+ t.isbn :exact_length => 15
+ t.author :alphanumeric => true
+ end
+
+ assert_prohibits :title, :not_blank do |book|
+ book.title = ' '
+ end
+
+ assert_prohibits :isbn, :exact_length do |book|
+ book.isbn = 'asdf'
+ end
+
+ assert_prohibits :author, :alphanumeric do |book|
+ book.author = 'foo#bar'
+ end
+
+ ActiveRecord::Migration.deconstrain :books do |t|
+ t.title :not_blank
+ t.isbn :exact_length
+ t.author :alphanumeric
+ end
+
+ assert_allows do |book|
+ book.title = ' '
+ book.isbn = 'asdf'
+ book.author = 'foo#bar'
+ end
+ end
+
+ def test_multiple_constraints_per_line
+ ActiveRecord::Migration.constrain :books do |t|
+ t.title :not_blank => true, :alphanumeric => true, :blacklist => %w(foo bar)
+ end
+
+ assert_prohibits :title, :not_blank do |book|
+ book.title = ' '
+ end
+
+ assert_prohibits :title, :alphanumeric do |book|
+ book.title = 'asdf@asdf'
+ end
+
+ assert_prohibits :title, :blacklist do |book|
+ book.title = 'foo'
+ end
+
+ ActiveRecord::Migration.deconstrain :books do |t|
+ t.title :not_blank, :alphanumeric, :blacklist
+ end
+
+ assert_allows do |book|
+ book.title = ' '
+ end
+
+ assert_allows do |book|
+ book.title = 'asdf@asdf'
+ end
+
+ assert_allows do |book|
+ book.title = 'foo'
+ end
+ end
+
+ private
+ def assert_prohibits(column, constraint, constraint_type = 'check')
+ book = Book.new
+ yield(book)
+ assert book.valid?
+ error = assert_raise ActiveRecord::StatementInvalid do
+ book.save
+ end
+ assert_match /PGError/, error.message
+ assert_match /violates #{constraint_type} constraint "books_#{column}_#{constraint}"/, error.message
+ end
+
+ def assert_allows
+ first_count = Book.count
+ book = Book.new
+ yield(book)
+ assert book.valid?
+ assert_nothing_raised do
+ book.save
+ end
+ assert_equal Book.count, first_count + 1
+ end
+end
5 test/test_helper.rb
@@ -0,0 +1,5 @@
+require 'rubygems'
+require 'test/unit'
+require 'active_record'
+require 'postgresql_adapter'
+require "sexy_pg_constraints"

0 comments on commit c31b499

Please sign in to comment.
Something went wrong with that request. Please try again.