Permalink
Browse files

initial commit

  • Loading branch information...
maxim committed Nov 23, 2008
0 parents commit c31b499252e35af133d0699b2435604da84eb2bb
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
@@ -0,0 +1,4 @@
+.DS_Store
+pkg
+doc
+Manifest
@@ -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.
@@ -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 }
@@ -0,0 +1 @@
+require 'sexy_pg_constraints'
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
Oops, something went wrong.

0 comments on commit c31b499

Please sign in to comment.