├── .gitignore ├── .rspec ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── img ├── bendyworks_github_footer.png └── reactive_record.png ├── lib ├── code_generator.rb ├── generators │ └── reactive_record │ │ └── install_generator.rb ├── lexer.rb ├── parser.rb ├── parser.y ├── reactive_record.rb └── reactive_record │ └── version.rb ├── reactive_record.gemspec └── spec ├── lexer_spec.rb ├── parser_spec.rb ├── reactive_record_spec.rb └── seed └── database.sql /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .ruby-version 19 | .ruby-gemset 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in reactive_record.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Chris Wilson & Joe Nelson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Reactive record logo 4 |
5 | Logo design: Kelly Rauwerdink, 6 | @missingdink, 7 | Dribbble. 8 | 9 |

10 | 11 | [![Gem Version](https://badge.fury.io/rb/reactive_record.png)](http://badge.fury.io/rb/reactive_record) 12 | 13 | Generates ActiveRecord models to fit a pre-existing Postgres database. 14 | Now you can use Rails with the db schema you always wanted. It's 15 | **your** convention over configuration. 16 | 17 | ## Why? 18 | 19 | 1. Your app is specific to Postgres and proud of it. You use the mature 20 | declarative data validation that only a real database can provide. 21 | 1. You have inherited a database or are more comfortable creating one 22 | yourself. 23 | 1. You're a grown-ass DBA who doesn't want to speak ORM baby-talk. 24 | 25 | ## Features 26 | 27 | * Fully automatic. It just works. 28 | * Creates a model for every table. 29 | * Creates a comprehensive initial migration. 30 | * Declares key-, uniqueness-, and presence-constraints. 31 | * Creates associations. 32 | * Adds custom validation methods for `CHECK` constraints. 33 | 34 | ## Usage 35 | 36 | ### Already familiar with Rails? 37 | 38 | * Set up a postgres db normally 39 | * Set `config.active_record.schema_format = :sql` to use a SQL `schema.rb` 40 | * After you have migrated up a table, use `rails generate reactive_record:install` 41 | * Go examine your generated models 42 | 43 | ### Want more details? 44 | 45 | **First** Include the `reactive_record` gem in your project's 46 | `Gemfile`. *Oh by the way*, you'll have to use postgres in your 47 | project. Setting up Rails for use with postgres is a bit outside 48 | the scope of this document. Please see [Configuring a Database] 49 | (http://guides.rubyonrails.org/configuring.html#configuring-a-database) 50 | for what you need to do. 51 | 52 | ``` ruby 53 | gem 'reactive_record' 54 | ``` 55 | 56 | Bundle to include the library 57 | 58 | ``` shell 59 | $ bundle 60 | ``` 61 | 62 | **Next** Tell `ActiveRecord` to go into beast-mode. Edit your 63 | `config/application.rb`, adding this line to use sql as the schema 64 | format: 65 | 66 | ``` ruby 67 | module YourApp 68 | class Application < Rails::Application 69 | # other configuration bric-a-brac... 70 | config.active_record.schema_format = :sql 71 | end 72 | end 73 | ``` 74 | 75 | **Next** Create the database(s) just like you normally would: 76 | 77 | ``` shell 78 | rake db:create 79 | ``` 80 | 81 | **Next** Generate a migration that will create the initial table: 82 | 83 | ``` shell 84 | $ rails generate migration create_employees 85 | ``` 86 | 87 | Use your SQL powers to craft some 88 | [DDL](http://en.wikipedia.org/wiki/Data_definition_language), perhaps 89 | the "Hello, World!" of DB applications, `employees`? 90 | 91 | ``` ruby 92 | class CreateEmployees < ActiveRecord::Migration 93 | def up 94 | execute <<-SQL 95 | CREATE TABLE employees ( 96 | id SERIAL, 97 | name VARCHAR(255) NOT NULL, 98 | email VARCHAR(255) NOT NULL UNIQUE, 99 | start_date DATE NOT NULL, 100 | 101 | PRIMARY KEY (id), 102 | CONSTRAINT company_email CHECK (email LIKE '%@example.com') 103 | ); 104 | SQL 105 | end 106 | 107 | def down 108 | drop_table :employees 109 | end 110 | end 111 | ``` 112 | 113 | **Lastly** Deploy the `reactive_record` generator: 114 | 115 | ``` shell 116 | $ rails generate reactive_record:install 117 | ``` 118 | 119 | Go look at the generated file: 120 | 121 | ``` ruby 122 | class Employees < ActiveRecord::Base 123 | set_table_name 'employees' 124 | set_primary_key :id 125 | validate :id, :name, :email, :start_date, presence: true 126 | validate :email, uniqueness: true 127 | validate { errors.add(:email, "Expected TODO") unless email =~ /.*@example.com/ } 128 | end 129 | ``` 130 | 131 | Reactive record does not currently attempt to generate any kind of 132 | reasonable error message (I'm working on it) :) 133 | 134 | **Enjoy** 135 | 136 | ## Credits 137 | 138 | Firstly, thank you, 139 | [contributors](https://github.com/twopoint718/reactive_record/graphs/contributors)! 140 | 141 | Also a special thanks to Joe Nelson, 142 | [@begriffs](https://github.com/begriffs), for contributions and 143 | inspiration; Reactive Record would not exist without his efforts. Thanks to [Bendyworks](http://bendyworks.com/) for the 20% 144 | time to work on this project! 145 | 146 | And, of *course*, a huge thank you to Kelly Rauwerdink for her amazing ability to make "an art" even when all I can do is sorta half-articulate what I'm talking about. Thanks! 147 | 148 | ![Footer](img/bendyworks_github_footer.png) 149 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | rule '.rb' => '.y' do |t| 7 | sh "racc -l -o #{t.name} #{t.source}" 8 | end 9 | 10 | task :compile => 'lib/parser.rb' 11 | 12 | task :spec => :compile 13 | 14 | task :default => :spec 15 | -------------------------------------------------------------------------------- /img/bendyworks_github_footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twopoint718/reactive_record/0d688eeeb508933d239e00ed3740dc5b93160720/img/bendyworks_github_footer.png -------------------------------------------------------------------------------- /img/reactive_record.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twopoint718/reactive_record/0d688eeeb508933d239e00ed3740dc5b93160720/img/reactive_record.png -------------------------------------------------------------------------------- /lib/code_generator.rb: -------------------------------------------------------------------------------- 1 | class Node 2 | def initialize val, *args 3 | @value = val 4 | @children = args 5 | end 6 | 7 | def gen 8 | raise "gen is not implemented: #{self}" 9 | end 10 | 11 | def column 12 | 'base' 13 | end 14 | end 15 | 16 | class UniqueNode < Node 17 | def initialize val; super(val, nil); end 18 | 19 | def gen 20 | "validates :#{@value.gen}, uniqueness: true" 21 | end 22 | end 23 | 24 | class IdentNode < Node 25 | def initialize val; super(val, nil); end 26 | 27 | def gen 28 | # bottoms out: IDENT is a string 29 | @value 30 | end 31 | 32 | def column 33 | # ident is likely a column (must be?) 34 | @value 35 | end 36 | end 37 | 38 | class ExprNode < Node 39 | def initialize val; super(val); end 40 | 41 | def column 42 | @value.column 43 | end 44 | 45 | def gen 46 | @value.gen 47 | end 48 | end 49 | 50 | class EmptyExprNode < Node 51 | def gen 52 | nil 53 | end 54 | 55 | def column 56 | nil 57 | end 58 | end 59 | 60 | class CheckNode < Node 61 | def initialize val; super(val); end 62 | 63 | def gen 64 | column = @value.column 65 | val = @value.gen 66 | if val 67 | "validate { errors.add(:#{column}, \"Expected TODO\") unless #{val} }" 68 | else 69 | "validate { true }" 70 | end 71 | end 72 | end 73 | 74 | class TypedExprNode < Node 75 | def initialize val, *args 76 | @children = args 77 | @type = @children[0] 78 | @value = case @type.gen 79 | when 'text' then TextNode.new(val) 80 | when 'date' then DateExprNode.new(val) 81 | else 82 | raise "Unknown type: #{@children[0]}" 83 | end 84 | end 85 | 86 | def column 87 | @value.column 88 | end 89 | 90 | def gen 91 | @value.gen 92 | end 93 | end 94 | 95 | class TextNode < Node 96 | def gen 97 | @value.gen 98 | end 99 | 100 | def column 101 | @value.column 102 | end 103 | end 104 | 105 | class DateExprNode < Node 106 | def initialize val 107 | @value = val 108 | end 109 | 110 | def gen 111 | val = @value.gen 112 | if val == 'now' 113 | "Time.now.to_date" 114 | else 115 | # YYYY-MM-DD 116 | "Date.parse(\"#{val}\")" 117 | end 118 | end 119 | end 120 | 121 | class StrLitNode < Node 122 | def initialize val; super(val); end 123 | 124 | def gen 125 | #FIXME HACK 126 | if @value.respond_to? :gen 127 | val = @value.gen 128 | else 129 | val = @value 130 | end 131 | val.gsub(/^'(.*)'$/, '\1') 132 | end 133 | end 134 | 135 | class IntNode < Node 136 | def initialize val; super(val); end 137 | 138 | def gen 139 | @value.to_s 140 | end 141 | end 142 | 143 | class OperatorNode < Node 144 | def initialize op, *args 145 | @value = op 146 | @children = args 147 | @expr1 = @children[0] 148 | @expr2 = @children[1] 149 | end 150 | 151 | def operation 152 | case @value 153 | when :gteq, :lteq, :neq, :eq, :gt, :lt 154 | ComparisonExpr.new @value, @expr1, @expr2 155 | when :plus 156 | MathExpr.new @value, @expr1, @expr2 157 | when :match 158 | MatchExpr.new @expr1, @expr2 159 | when :and 160 | AndExpr.new @expr1, @expr2 161 | end 162 | end 163 | 164 | def error_msg 165 | case @value 166 | when :gteq then 'to be greater than or equal to' 167 | when :lteq then 'to be less than or equal to' 168 | when :neq then 'to not equal' 169 | when :eq then 'to be equal to' 170 | when :gt then 'to be greater than' 171 | when :lt then 'to be less than' 172 | when :plus then 'plus' 173 | when :match then 'to match' 174 | end 175 | end 176 | 177 | def column 178 | c1 = @expr1.column 179 | c2 = @expr1.column 180 | return c1 if c1 != 'base' 181 | return c2 if c2 != 'base' 182 | 'base' 183 | end 184 | 185 | def gen 186 | operation.gen 187 | end 188 | end 189 | 190 | class ComparisonExpr 191 | def initialize comparison, e1, e2 192 | @e1, @e2 = e1, e2 193 | @comparison = { 194 | gteq: '>=', 195 | lteq: '<=', 196 | neq: '!=', 197 | eq: '==', 198 | gt: '>', 199 | lt: '<', 200 | }[comparison] 201 | end 202 | 203 | def gen 204 | "#{@e1.gen} #{@comparison} #{@e2.gen}" 205 | end 206 | end 207 | 208 | class AndExpr 209 | def initialize e1, e2 210 | @e1, @e2 = e1, e2 211 | end 212 | 213 | def gen 214 | "#{@e1.gen} && #{@e2.gen}" 215 | end 216 | end 217 | 218 | class MathExpr 219 | def initialize op, e1, e2 220 | @e1, @e2 = e1, e2 221 | @operation = { plus: '+', minus: '-' }[op] 222 | end 223 | 224 | def gen 225 | "#{@e1.gen} #{@operation} #{@e2.gen}" 226 | end 227 | end 228 | 229 | class MatchExpr 230 | def initialize e1, e2 231 | @e1 = e1 232 | @e2 = e2 233 | end 234 | 235 | def convert_wildcard str 236 | str.gsub(/%/, '.*') 237 | end 238 | 239 | def gen 240 | #raise "RHS: #{@e2.class} #{@e2.gen.class}" 241 | "#{@e1.gen} =~ /#{convert_wildcard @e2.gen}/" 242 | end 243 | end 244 | 245 | class TableNode < Node 246 | def initialize tab, col 247 | @tab = tab 248 | @col = col 249 | end 250 | 251 | def table_to_class 252 | @tab.gen.capitalize 253 | end 254 | 255 | def key_name 256 | @col.gen 257 | end 258 | 259 | def gen 260 | @tab.gen 261 | end 262 | end 263 | 264 | class ActionNode < Node 265 | def initialize action, consequence 266 | @action = action 267 | @consequence = consequence 268 | end 269 | 270 | def gen 271 | "ON #{@action} #{@consequence}" 272 | end 273 | end 274 | 275 | class ForeignKeyNode < Node 276 | def initialize col, table, *actions 277 | @col = col 278 | @table = table 279 | if actions.count > 0 280 | @action = actions[0] 281 | end 282 | end 283 | 284 | def gen 285 | col = @col.gen 286 | table_name = @table.gen 287 | class_name = @table.table_to_class 288 | key = @table.key_name 289 | "belongs_to :#{table_name}, foreign_key: '#{col}', class: '#{class_name}', primary_key: '#{key}'" 290 | end 291 | end 292 | -------------------------------------------------------------------------------- /lib/generators/reactive_record/install_generator.rb: -------------------------------------------------------------------------------- 1 | require "reactive_record" 2 | 3 | module ReactiveRecord 4 | module Generators 5 | class InstallGenerator < Rails::Generators::Base 6 | include ReactiveRecord 7 | 8 | desc "Adds models based upon your existing Postgres DB" 9 | 10 | def create_models 11 | db_env = Rails.configuration.database_configuration[Rails.env] 12 | raise 'You must use the pg database adapter' unless db_env['adapter'] == 'postgresql' 13 | db = PG.connect dbname: db_env['database'] 14 | table_names(db).each do |table| 15 | unless table == 'schema_migrations' 16 | create_file "app/models/#{table.underscore.pluralize}.rb", model_definition(db, table) 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | 24 | -------------------------------------------------------------------------------- /lib/lexer.rb: -------------------------------------------------------------------------------- 1 | require 'strscan' 2 | 3 | module ConstraintParser 4 | class Lexer 5 | AND = /AND/ 6 | CASCADE = /CASCADE/ 7 | CHECK = /CHECK/ 8 | COMMA = /,/ 9 | DELETE = /DELETE/ 10 | EQ = /\=/ 11 | FOREIGN_KEY = /FOREIGN KEY/ 12 | GT = /\>/ 13 | GTEQ = /\>=/ 14 | IDENT = /[a-zA-Z][a-zA-Z0-9_]*/ 15 | INT = /[0-9]+/ 16 | LPAREN = /\(/ 17 | LT = /\/ 21 | NEWLINE = /\n/ 22 | ON = /ON/ 23 | PLUS = /\+/ 24 | PRIMARY_KEY = /PRIMARY KEY/ 25 | REFERENCES = /REFERENCES/ 26 | RESTRICT = /RESTRICT/ 27 | RPAREN = /\)/ 28 | SPACE = /[\ \t]+/ 29 | STRLIT = /\'(\\.|[^\\'])*\'/ 30 | TYPE = /::/ 31 | UNIQUE = /UNIQUE/ 32 | 33 | def initialize io 34 | @ss = StringScanner.new io.read 35 | end 36 | 37 | def next_token 38 | return if @ss.eos? 39 | 40 | result = false 41 | while !result 42 | result = case 43 | when text = @ss.scan(SPACE) then #ignore whitespace 44 | 45 | # Operators 46 | when text = @ss.scan(GTEQ) then [:GTEQ, text] 47 | when text = @ss.scan(LTEQ) then [:LTEQ, text] 48 | when text = @ss.scan(NEQ) then [:NEQ, text] 49 | when text = @ss.scan(GT) then [:GT, text] 50 | when text = @ss.scan(LT) then [:LT, text] 51 | when text = @ss.scan(EQ) then [:EQ, text] 52 | when text = @ss.scan(PLUS) then [:PLUS, text] 53 | when text = @ss.scan(MATCH_OP) then [:MATCH_OP, text] 54 | when text = @ss.scan(AND) then [:AND, text] 55 | 56 | # SQL Keywords 57 | when text = @ss.scan(CHECK) then [:CHECK, text] 58 | when text = @ss.scan(UNIQUE) then [:UNIQUE, text] 59 | when text = @ss.scan(PRIMARY_KEY) then [:PRIMARY_KEY, text] 60 | when text = @ss.scan(FOREIGN_KEY) then [:FOREIGN_KEY, text] 61 | when text = @ss.scan(REFERENCES) then [:REFERENCES, text] 62 | when text = @ss.scan(DELETE) then [:DELETE, text] 63 | when text = @ss.scan(ON) then [:ON, text] 64 | when text = @ss.scan(RESTRICT) then [:RESTRICT, text] 65 | when text = @ss.scan(CASCADE) then [:CASCADE, text] 66 | 67 | # Values 68 | when text = @ss.scan(IDENT) then [:IDENT, text] 69 | when text = @ss.scan(STRLIT) then [:STRLIT, text] 70 | when text = @ss.scan(INT) then [:INT, text] 71 | 72 | # Syntax 73 | when text = @ss.scan(LPAREN) then [:LPAREN, text] 74 | when text = @ss.scan(RPAREN) then [:RPAREN, text] 75 | when text = @ss.scan(TYPE) then [:TYPE, text] 76 | when text = @ss.scan(COMMA) then [:COMMA, text] 77 | else 78 | x = @ss.getch 79 | [x, x] 80 | end 81 | end 82 | result 83 | end 84 | 85 | def tokenize 86 | out = [] 87 | while (tok = next_token) 88 | out << tok 89 | end 90 | out 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/parser.rb: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT MODIFY!!!! 3 | # This file is automatically generated by Racc 1.4.9 4 | # from Racc grammer file "". 5 | # 6 | 7 | require 'racc/parser.rb' 8 | module ConstraintParser 9 | class Parser < Racc::Parser 10 | 11 | 12 | require 'lexer' 13 | require 'code_generator' 14 | 15 | def initialize tokenizer, handler = nil 16 | @tokenizer = tokenizer 17 | super() 18 | end 19 | 20 | def next_token 21 | @tokenizer.next_token 22 | end 23 | 24 | def parse 25 | do_parse 26 | end 27 | ##### State transition tables begin ### 28 | 29 | racc_action_table = [ 30 | 27, 28, 21, 22, 23, 24, 25, 26, 29, 27, 31 | 28, 21, 22, 23, 24, 25, 26, 29, 46, 12, 32 | 14, 11, 12, 14, 11, 30, 36, 32, 6, 13, 33 | 45, 7, 13, 33, 30, 27, 28, 21, 22, 23, 34 | 24, 25, 26, 29, 5, 12, 14, 11, 16, 18, 35 | 17, 9, 39, 40, 8, 13, 42, 37, 16, 44, 36 | 30, 34 ] 37 | 38 | racc_action_check = [ 39 | 10, 10, 10, 10, 10, 10, 10, 10, 10, 35, 40 | 35, 35, 35, 35, 35, 35, 35, 35, 44, 11, 41 | 11, 11, 20, 20, 20, 10, 30, 15, 0, 11, 42 | 44, 0, 20, 16, 35, 31, 31, 31, 31, 31, 43 | 31, 31, 31, 31, 0, 6, 6, 6, 7, 9, 44 | 8, 5, 32, 33, 1, 6, 38, 31, 39, 42, 45 | 31, 18 ] 46 | 47 | racc_action_pointer = [ 48 | 16, 54, nil, nil, nil, 33, 29, 30, 50, 33, 49 | -2, 3, nil, nil, nil, 5, 17, nil, 37, nil, 50 | 6, nil, nil, nil, nil, nil, nil, nil, nil, nil, 51 | 10, 33, 36, 29, nil, 7, nil, nil, 36, 40, 52 | nil, nil, 45, nil, 7, nil, nil ] 53 | 54 | racc_action_default = [ 55 | -29, -29, -1, -2, -3, -29, -6, -29, -29, -29, 56 | -5, -6, -10, -11, -12, -29, -29, 47, -29, -8, 57 | -6, -13, -14, -15, -16, -17, -18, -19, -20, -21, 58 | -29, -29, -29, -29, -4, -9, -22, -7, -23, -29, 59 | -25, -24, -29, -26, -29, -27, -28 ] 60 | 61 | racc_goto_table = [ 62 | 15, 10, 1, 4, 3, 2, 31, 38, 41, nil, 63 | nil, nil, nil, nil, nil, 35, nil, nil, nil, nil, 64 | nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, 65 | nil, nil, 43 ] 66 | 67 | racc_goto_check = [ 68 | 8, 5, 1, 4, 3, 2, 5, 9, 10, nil, 69 | nil, nil, nil, nil, nil, 5, nil, nil, nil, nil, 70 | nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, 71 | nil, nil, 8 ] 72 | 73 | racc_goto_pointer = [ 74 | nil, 2, 5, 4, 3, -5, nil, nil, -7, -25, 75 | -30 ] 76 | 77 | racc_goto_default = [ 78 | nil, nil, nil, nil, nil, nil, 19, 20, nil, nil, 79 | nil ] 80 | 81 | racc_reduce_table = [ 82 | 0, 0, :racc_error, 83 | 1, 30, :_reduce_1, 84 | 1, 30, :_reduce_2, 85 | 1, 30, :_reduce_3, 86 | 4, 31, :_reduce_4, 87 | 2, 32, :_reduce_5, 88 | 0, 34, :_reduce_6, 89 | 3, 34, :_reduce_7, 90 | 2, 34, :_reduce_8, 91 | 3, 34, :_reduce_9, 92 | 1, 34, :_reduce_10, 93 | 1, 34, :_reduce_11, 94 | 1, 34, :_reduce_12, 95 | 1, 36, :_reduce_13, 96 | 1, 36, :_reduce_14, 97 | 1, 36, :_reduce_15, 98 | 1, 36, :_reduce_16, 99 | 1, 36, :_reduce_17, 100 | 1, 36, :_reduce_18, 101 | 1, 36, :_reduce_19, 102 | 1, 36, :_reduce_20, 103 | 1, 36, :_reduce_21, 104 | 2, 35, :_reduce_22, 105 | 4, 33, :_reduce_23, 106 | 5, 33, :_reduce_24, 107 | 3, 37, :_reduce_25, 108 | 2, 38, :_reduce_26, 109 | 3, 39, :_reduce_27, 110 | 3, 39, :_reduce_28 ] 111 | 112 | racc_reduce_n = 29 113 | 114 | racc_shift_n = 47 115 | 116 | racc_token_table = { 117 | false => 0, 118 | :error => 1, 119 | :PLUS => 2, 120 | :MATCH_OP => 3, 121 | :GTEQ => 4, 122 | :LTEQ => 5, 123 | :NEQ => 6, 124 | :EQ => 7, 125 | :GT => 8, 126 | :LT => 9, 127 | :AND => 10, 128 | :CASCADE => 11, 129 | :CHECK => 12, 130 | :COMMA => 13, 131 | :DELETE => 14, 132 | :FOREIGN_KEY => 15, 133 | :IDENT => 16, 134 | :INT => 17, 135 | :LPAREN => 18, 136 | :NEWLINE => 19, 137 | :ON => 20, 138 | :PRIMARY_KEY => 21, 139 | :REFERENCES => 22, 140 | :RESTRICT => 23, 141 | :RPAREN => 24, 142 | :SPACE => 25, 143 | :STRLIT => 26, 144 | :TYPE => 27, 145 | :UNIQUE => 28 } 146 | 147 | racc_nt_base = 29 148 | 149 | racc_use_result_var = true 150 | 151 | Racc_arg = [ 152 | racc_action_table, 153 | racc_action_check, 154 | racc_action_default, 155 | racc_action_pointer, 156 | racc_goto_table, 157 | racc_goto_check, 158 | racc_goto_default, 159 | racc_goto_pointer, 160 | racc_nt_base, 161 | racc_reduce_table, 162 | racc_token_table, 163 | racc_shift_n, 164 | racc_reduce_n, 165 | racc_use_result_var ] 166 | 167 | Racc_token_to_s_table = [ 168 | "$end", 169 | "error", 170 | "PLUS", 171 | "MATCH_OP", 172 | "GTEQ", 173 | "LTEQ", 174 | "NEQ", 175 | "EQ", 176 | "GT", 177 | "LT", 178 | "AND", 179 | "CASCADE", 180 | "CHECK", 181 | "COMMA", 182 | "DELETE", 183 | "FOREIGN_KEY", 184 | "IDENT", 185 | "INT", 186 | "LPAREN", 187 | "NEWLINE", 188 | "ON", 189 | "PRIMARY_KEY", 190 | "REFERENCES", 191 | "RESTRICT", 192 | "RPAREN", 193 | "SPACE", 194 | "STRLIT", 195 | "TYPE", 196 | "UNIQUE", 197 | "$start", 198 | "constraint", 199 | "unique_column", 200 | "check_statement", 201 | "foreign_key", 202 | "expr", 203 | "type_signature", 204 | "operator", 205 | "column_spec", 206 | "table_spec", 207 | "action_spec" ] 208 | 209 | Racc_debug_parser = false 210 | 211 | ##### State transition tables end ##### 212 | 213 | # reduce 0 omitted 214 | 215 | def _reduce_1(val, _values, result) 216 | result = val[0] 217 | result 218 | end 219 | 220 | def _reduce_2(val, _values, result) 221 | result = val[0] 222 | result 223 | end 224 | 225 | def _reduce_3(val, _values, result) 226 | result = val[0] 227 | result 228 | end 229 | 230 | def _reduce_4(val, _values, result) 231 | result = UniqueNode.new(IdentNode.new(val[2])) 232 | result 233 | end 234 | 235 | def _reduce_5(val, _values, result) 236 | result = CheckNode.new(val[1]) 237 | result 238 | end 239 | 240 | def _reduce_6(val, _values, result) 241 | result = EmptyExprNode.new :empty 242 | result 243 | end 244 | 245 | def _reduce_7(val, _values, result) 246 | result = ExprNode.new(val[1]) 247 | result 248 | end 249 | 250 | def _reduce_8(val, _values, result) 251 | result = TypedExprNode.new(val[0], val[1]) 252 | result 253 | end 254 | 255 | def _reduce_9(val, _values, result) 256 | result = OperatorNode.new(val[1], val[0], val[2]) 257 | result 258 | end 259 | 260 | def _reduce_10(val, _values, result) 261 | result = IdentNode.new(val[0]) 262 | result 263 | end 264 | 265 | def _reduce_11(val, _values, result) 266 | result = StrLitNode.new(val[0]) 267 | result 268 | end 269 | 270 | def _reduce_12(val, _values, result) 271 | result = IntNode.new(val[0]) 272 | result 273 | end 274 | 275 | def _reduce_13(val, _values, result) 276 | result = :gteq 277 | result 278 | end 279 | 280 | def _reduce_14(val, _values, result) 281 | result = :lteq 282 | result 283 | end 284 | 285 | def _reduce_15(val, _values, result) 286 | result = :neq 287 | result 288 | end 289 | 290 | def _reduce_16(val, _values, result) 291 | result = :eq 292 | result 293 | end 294 | 295 | def _reduce_17(val, _values, result) 296 | result = :gt 297 | result 298 | end 299 | 300 | def _reduce_18(val, _values, result) 301 | result = :lt 302 | result 303 | end 304 | 305 | def _reduce_19(val, _values, result) 306 | result = :plus 307 | result 308 | end 309 | 310 | def _reduce_20(val, _values, result) 311 | result = :match 312 | result 313 | end 314 | 315 | def _reduce_21(val, _values, result) 316 | result = :and 317 | result 318 | end 319 | 320 | def _reduce_22(val, _values, result) 321 | result = IdentNode.new(val[1]) 322 | result 323 | end 324 | 325 | def _reduce_23(val, _values, result) 326 | result = ForeignKeyNode.new(val[1], val[3]) 327 | result 328 | end 329 | 330 | def _reduce_24(val, _values, result) 331 | result = ForeignKeyNode.new(val[1], val[3], val[4]) 332 | result 333 | end 334 | 335 | def _reduce_25(val, _values, result) 336 | result = IdentNode.new(val[1]) 337 | result 338 | end 339 | 340 | def _reduce_26(val, _values, result) 341 | result = TableNode.new(IdentNode.new(val[0]), val[1]) 342 | result 343 | end 344 | 345 | def _reduce_27(val, _values, result) 346 | result = ActionNode.new(:delete, :restrict) 347 | result 348 | end 349 | 350 | def _reduce_28(val, _values, result) 351 | result = ActionNode.new(:delete, :cascade) 352 | result 353 | end 354 | 355 | def _reduce_none(val, _values, result) 356 | val[0] 357 | end 358 | 359 | end # class Parser 360 | end # module ConstraintParser 361 | -------------------------------------------------------------------------------- /lib/parser.y: -------------------------------------------------------------------------------- 1 | class ConstraintParser::Parser 2 | prechigh 3 | left PLUS MATCH_OP 4 | left GTEQ LTEQ NEQ EQ GT LT 5 | preclow 6 | 7 | token AND 8 | CASCADE 9 | CHECK 10 | COMMA 11 | DELETE 12 | EQ 13 | FOREIGN_KEY 14 | GT 15 | GTEQ 16 | IDENT 17 | INT 18 | LPAREN 19 | LT 20 | LTEQ 21 | MATCH_OP 22 | NEQ 23 | NEWLINE 24 | ON 25 | PLUS 26 | PRIMARY_KEY 27 | REFERENCES 28 | RESTRICT 29 | RPAREN 30 | SPACE 31 | STRLIT 32 | TYPE 33 | UNIQUE 34 | 35 | rule 36 | constraint : unique_column { result = val[0] } 37 | | check_statement { result = val[0] } 38 | | foreign_key { result = val[0] } 39 | ; 40 | 41 | unique_column : UNIQUE LPAREN IDENT RPAREN { result = UniqueNode.new(IdentNode.new(val[2])) } 42 | ; 43 | 44 | check_statement : CHECK expr { result = CheckNode.new(val[1]) } 45 | 46 | expr : { result = EmptyExprNode.new :empty } 47 | | LPAREN expr RPAREN { result = ExprNode.new(val[1]) } 48 | | expr type_signature { result = TypedExprNode.new(val[0], val[1]) } 49 | | expr operator expr { result = OperatorNode.new(val[1], val[0], val[2]) } 50 | | IDENT { result = IdentNode.new(val[0]) } 51 | | STRLIT { result = StrLitNode.new(val[0]) } 52 | | INT { result = IntNode.new(val[0]) } 53 | ; 54 | 55 | operator : GTEQ { result = :gteq } 56 | | LTEQ { result = :lteq } 57 | | NEQ { result = :neq } 58 | | EQ { result = :eq } 59 | | GT { result = :gt } 60 | | LT { result = :lt } 61 | | PLUS { result = :plus } 62 | | MATCH_OP { result = :match } 63 | | AND { result = :and } 64 | ; 65 | 66 | type_signature : TYPE IDENT { result = IdentNode.new(val[1]) } 67 | ; 68 | 69 | foreign_key : FOREIGN_KEY column_spec REFERENCES table_spec { result = ForeignKeyNode.new(val[1], val[3]) } 70 | | FOREIGN_KEY column_spec REFERENCES table_spec action_spec { result = ForeignKeyNode.new(val[1], val[3], val[4]) } 71 | ; 72 | 73 | column_spec : LPAREN IDENT RPAREN { result = IdentNode.new(val[1]) } 74 | ; 75 | 76 | table_spec : IDENT column_spec { result = TableNode.new(IdentNode.new(val[0]), val[1]) } 77 | ; 78 | 79 | action_spec : ON DELETE RESTRICT { result = ActionNode.new(:delete, :restrict) } 80 | | ON DELETE CASCADE { result = ActionNode.new(:delete, :cascade) } 81 | ; 82 | end 83 | 84 | ---- inner 85 | 86 | require 'lexer' 87 | require 'code_generator' 88 | 89 | def initialize tokenizer, handler = nil 90 | @tokenizer = tokenizer 91 | super() 92 | end 93 | 94 | def next_token 95 | @tokenizer.next_token 96 | end 97 | 98 | def parse 99 | do_parse 100 | end 101 | -------------------------------------------------------------------------------- /lib/reactive_record.rb: -------------------------------------------------------------------------------- 1 | require_relative "./reactive_record/version" 2 | 3 | require 'pg' 4 | require 'active_support/inflector' 5 | require 'parser' 6 | 7 | module ReactiveRecord 8 | def model_definition db, table_name 9 | header = "class #{table_name.classify.pluralize} < ActiveRecord::Base\n" 10 | footer = "end\n" 11 | 12 | body = [] 13 | body << "set_table_name '#{table_name}'" 14 | body << "set_primary_key :#{primary_key db, table_name}" 15 | body << "#{validate_definition non_nullable_columns(db, table_name), 'presence'}" 16 | body << "#{validate_definition unique_columns(db, table_name), 'uniqueness'}" 17 | 18 | generate_constraints(db, table_name).each do |con| 19 | body << con 20 | end 21 | 22 | indent = " " 23 | body = indent + body.join("\n" + indent) + "\n" 24 | header + body + footer 25 | end 26 | 27 | def validate_definition cols, type 28 | return '' if cols.empty? 29 | symbols = cols.map { |c| ":#{c}" } 30 | "validate #{symbols.join ', '}, #{type}: true" 31 | end 32 | 33 | def table_names db 34 | results = db.exec( 35 | "select table_name from information_schema.tables where table_schema = $1", 36 | ['public'] 37 | ) 38 | results.map { |r| r['table_name'] } 39 | end 40 | 41 | def constraints db, table 42 | db.exec(""" 43 | SELECT c.relname AS table_name, 44 | con.conname AS constraint_name, 45 | pg_get_constraintdef( con.oid, false) AS constraint_src 46 | FROM pg_constraint con 47 | JOIN pg_namespace n on (n.oid = con.connamespace) 48 | JOIN pg_class c on (c.oid = con.conrelid) 49 | WHERE con.conrelid != 0 50 | AND c.relname = $1 51 | ORDER BY con.conname; 52 | """, [table]) 53 | end 54 | 55 | def generate_constraints db, table 56 | key_or_pkey = lambda do |row| 57 | row['constraint_name'].end_with?('_key') || row['constraint_name'].end_with?('_pkey') 58 | end 59 | 60 | constraints(db, table) 61 | .reject(&key_or_pkey) 62 | .map(&parse_constraint) 63 | end 64 | 65 | def parse_constraint 66 | lambda do |row| 67 | src = row['constraint_src'] 68 | parser = ConstraintParser::Parser.new(ConstraintParser::Lexer.new(StringIO.new src)) 69 | parser.parse.gen 70 | end 71 | end 72 | 73 | def cols_with_contype db, table_name, type 74 | db.exec """ 75 | SELECT column_name, conname 76 | FROM pg_constraint, information_schema.columns 77 | WHERE table_name = $1 78 | AND contype = $2 79 | AND ordinal_position = any(conkey); 80 | """, [table_name, type] 81 | end 82 | 83 | def column_name 84 | lambda {|row| row['column_name']} 85 | end 86 | 87 | def table_name 88 | lambda {|row| row['table_name']} 89 | end 90 | 91 | def primary_key db, table_name 92 | matching_primary_key = lambda {|row| row['conname'] == "#{table_name}_pkey"} 93 | cols_with_contype(db, table_name, 'p') 94 | .select(&matching_primary_key) 95 | .map(&column_name) 96 | .first 97 | end 98 | 99 | def unique_columns db, table_name 100 | matching_unique_constraint = lambda {|row| row['conname'] == "#{table_name}_#{row['column_name']}_key"} 101 | cols_with_contype(db, table_name, 'u') 102 | .select(&matching_unique_constraint) 103 | .map(&column_name) 104 | end 105 | 106 | def non_nullable_columns db, table_name 107 | result = db.exec """ 108 | SELECT column_name 109 | FROM information_schema.columns 110 | WHERE table_name = $1 111 | AND is_nullable = $2 112 | """, [table_name, 'NO'] 113 | result.map { |r| r['column_name'] } 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/reactive_record/version.rb: -------------------------------------------------------------------------------- 1 | module ReactiveRecord 2 | VERSION = "0.0.4" 3 | end 4 | -------------------------------------------------------------------------------- /reactive_record.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'reactive_record/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "reactive_record" 8 | gem.version = ReactiveRecord::VERSION 9 | gem.authors = ["Joe Nelson", "Chris Wilson"] 10 | gem.email = ["christopher.j.wilson@gmail.com"] 11 | gem.description = %q{Generate ActiveRecord models from a pre-existing Postgres db} 12 | gem.summary = %q{Use the schema you always wanted.} 13 | gem.homepage = "https://github.com/twopoint718/reactive_record" 14 | gem.licenses = ['MIT'] 15 | 16 | gem.files = `git ls-files`.split("\n") 17 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 18 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 19 | gem.require_paths = ["lib"] 20 | 21 | gem.add_dependency "pg" 22 | gem.add_dependency "activesupport" 23 | 24 | gem.add_development_dependency 'rspec' 25 | gem.add_development_dependency 'racc' 26 | end 27 | -------------------------------------------------------------------------------- /spec/lexer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require_relative '../lib/lexer.rb' 3 | 4 | describe ConstraintParser::Lexer do 5 | let(:lex){ConstraintParser::Lexer} 6 | 7 | it "CHECK (((email)::text ~~ '%@bendyworks.com'::text))" do 8 | @lex = lex.new \ 9 | StringIO.new "CHECK (((email)::text ~~ '%@bendyworks.com'::text))" 10 | @lex.tokenize.should == [ 11 | [:CHECK, 'CHECK'], 12 | [:LPAREN, '('], 13 | [:LPAREN, '('], 14 | [:LPAREN, '('], 15 | [:IDENT, 'email'], 16 | [:RPAREN, ')'], 17 | [:TYPE, '::'], 18 | [:IDENT, 'text'], 19 | [:MATCH_OP, '~~'], 20 | [:STRLIT, "'%@bendyworks.com'"], 21 | [:TYPE, '::'], 22 | [:IDENT, 'text'], 23 | [:RPAREN, ')'], 24 | [:RPAREN, ')'], 25 | ] 26 | end 27 | 28 | it "UNIQUE (email)" do 29 | @lex = lex.new StringIO.new "UNIQUE (email)" 30 | @lex.tokenize.should == [ 31 | [:UNIQUE, 'UNIQUE'], 32 | [:LPAREN, '('], 33 | [:IDENT, 'email'], 34 | [:RPAREN, ')'] 35 | ] 36 | end 37 | 38 | it "PRIMARY KEY (employee_id)" do 39 | @lex = lex.new StringIO.new "PRIMARY KEY (employee_id)" 40 | @lex.tokenize.should == [ 41 | [:PRIMARY_KEY, 'PRIMARY KEY'], 42 | [:LPAREN, '('], 43 | [:IDENT, 'employee_id'], 44 | [:RPAREN, ')'] 45 | ] 46 | end 47 | 48 | it "CHECK ((start_date >= '2009-04-13'::date))" do 49 | @lex = lex.new StringIO.new "CHECK ((start_date >= '2009-04-13'::date))" 50 | @lex.tokenize.should == [ 51 | [:CHECK, 'CHECK'], 52 | [:LPAREN, '('], 53 | [:LPAREN, '('], 54 | [:IDENT, 'start_date'], 55 | [:GTEQ, '>='], 56 | [:STRLIT, "'2009-04-13'"], 57 | [:TYPE, '::'], 58 | [:IDENT, 'date'], 59 | [:RPAREN, ')'], 60 | [:RPAREN, ')'], 61 | ] 62 | end 63 | 64 | it '"CHECK ((start_date <= ((\'now\'::text)::date + 365)))"' do 65 | @lex = lex.new StringIO.new "CHECK ((start_date <= ((\'now\'::text)::date + 365)))" 66 | @lex.tokenize.should == [ 67 | [:CHECK, 'CHECK'], 68 | [:LPAREN, '('], 69 | [:LPAREN, '('], 70 | [:IDENT, 'start_date'], 71 | [:LTEQ, '<='], 72 | [:LPAREN, '('], 73 | [:LPAREN, '('], 74 | [:STRLIT, "'now'"], 75 | [:TYPE, '::'], 76 | [:IDENT, 'text'], 77 | [:RPAREN, ')'], 78 | [:TYPE, '::'], 79 | [:IDENT, 'date'], 80 | [:PLUS, '+'], 81 | [:INT, '365'], 82 | [:RPAREN, ')'], 83 | [:RPAREN, ')'], 84 | [:RPAREN, ')'], 85 | ] 86 | end 87 | 88 | it "FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE RESTRICT" do 89 | @lex = lex.new StringIO.new "FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE RESTRICT" 90 | @lex.tokenize.should == [ 91 | [:FOREIGN_KEY, 'FOREIGN KEY'], 92 | [:LPAREN, '('], 93 | [:IDENT, 'employee_id'], 94 | [:RPAREN, ')'], 95 | [:REFERENCES, 'REFERENCES'], 96 | [:IDENT, 'employees'], 97 | [:LPAREN, '('], 98 | [:IDENT, 'employee_id'], 99 | [:RPAREN, ')'], 100 | [:ON, 'ON'], 101 | [:DELETE, 'DELETE'], 102 | [:RESTRICT, 'RESTRICT'], 103 | ] 104 | end 105 | 106 | it "PRIMARY KEY (employee_id, project_id)" do 107 | @lex = lex.new StringIO.new "PRIMARY KEY (employee_id, project_id)" 108 | @lex.tokenize.should == [ 109 | [:PRIMARY_KEY, 'PRIMARY KEY'], 110 | [:LPAREN, '('], 111 | [:IDENT, 'employee_id'], 112 | [:COMMA, ','], 113 | [:IDENT, 'project_id'], 114 | [:RPAREN, ')'] 115 | ] 116 | end 117 | 118 | it "FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE" do 119 | @lex = lex.new StringIO.new "FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE" 120 | @lex.tokenize.should == [ 121 | [:FOREIGN_KEY, 'FOREIGN KEY'], 122 | [:LPAREN, '('], 123 | [:IDENT, 'project_id'], 124 | [:RPAREN, ')'], 125 | [:REFERENCES, 'REFERENCES'], 126 | [:IDENT, 'projects'], 127 | [:LPAREN, '('], 128 | [:IDENT, 'project_id'], 129 | [:RPAREN, ')'], 130 | [:ON, 'ON'], 131 | [:DELETE, 'DELETE'], 132 | [:CASCADE, 'CASCADE'], 133 | ] 134 | end 135 | 136 | it "PRIMARY KEY (project_id)" do 137 | @lex = lex.new StringIO.new "PRIMARY KEY (project_id)" 138 | @lex.tokenize.should == [ 139 | [:PRIMARY_KEY, 'PRIMARY KEY'], 140 | [:LPAREN, '('], 141 | [:IDENT, 'project_id'], 142 | [:RPAREN, ')'], 143 | ] 144 | end 145 | it "CHECK (((port >= 1024) AND (port <= 65634)))" do 146 | @lex = lex.new StringIO.new "CHECK (((port >= 1024) AND (port <= 65634)))" 147 | @lex.tokenize.should == [ 148 | [:CHECK, 'CHECK'], 149 | [:LPAREN, '('], 150 | [:LPAREN, '('], 151 | [:LPAREN, '('], 152 | [:IDENT, 'port'], 153 | [:GTEQ, '>='], 154 | [:INT, "1024"], 155 | [:RPAREN, ')'], 156 | [:AND, 'AND'], 157 | [:LPAREN, '('], 158 | [:IDENT, 'port'], 159 | [:LTEQ, '<='], 160 | [:INT, "65634"], 161 | [:RPAREN, ')'], 162 | [:RPAREN, ')'], 163 | [:RPAREN, ')'], 164 | ] 165 | end 166 | 167 | end 168 | -------------------------------------------------------------------------------- /spec/parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require_relative '../lib/lexer.rb' 3 | require_relative '../lib/parser.rb' 4 | require_relative '../lib/code_generator.rb' 5 | 6 | describe ConstraintParser::Parser do 7 | let(:lex){ConstraintParser::Lexer} 8 | let(:par){ConstraintParser::Parser} 9 | 10 | it 'CHECK ()' do 11 | @parser = par.new(lex.new StringIO.new 'CHECK ()') 12 | @parser.parse.gen.should == 'validate { true }' 13 | end 14 | 15 | it "CHECK (((email)::text ~~ '%@bendyworks.com'::text))" do 16 | @parser = par.new(lex.new StringIO.new "CHECK (((email)::text ~~ '%@bendyworks.com'::text))") 17 | @parser.parse.gen.should == 'validate { errors.add(:email, "Expected TODO") unless email =~ /.*@bendyworks.com/ }' 18 | end 19 | 20 | it "UNIQUE (email)" do 21 | @parser = par.new(lex.new StringIO.new 'UNIQUE (email)') 22 | @parser.parse.gen.should == "validates :email, uniqueness: true" 23 | end 24 | 25 | it "CHECK ((start_date <= ('now'::text)::date))" do 26 | @parser = par.new(lex.new StringIO.new "CHECK ((start_date <= ('now'::text)::date))") 27 | @parser.parse.gen.should == "validate { errors.add(:start_date, \"Expected TODO\") unless start_date <= Time.now.to_date }" 28 | end 29 | 30 | it "CHECK ((start_date <= (('now'::text)::date + 365)))" do 31 | @parser = par.new(lex.new StringIO.new "CHECK ((start_date <= (('now'::text)::date + 365)))") 32 | @parser.parse.gen.should == 'validate { errors.add(:start_date, "Expected TODO") unless start_date <= Time.now.to_date + 365 }' 33 | end 34 | 35 | it "CHECK (((port >= 1024) AND (port <= 65634)))" do 36 | @parser = par.new(lex.new StringIO.new "CHECK (((port >= 1024) AND (port <= 65634)))") 37 | @parser.parse.gen.should == 'validate { errors.add(:port, "Expected TODO") unless port >= 1024 && port <= 65634 }' 38 | end 39 | 40 | it "FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE RESTRICT" do 41 | @parser = par.new(lex.new StringIO.new "FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE RESTRICT") 42 | @parser.parse.gen.should == "belongs_to :employees, foreign_key: 'employee_id', class: 'Employees', primary_key: 'employee_id'" 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/reactive_record_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require_relative '../lib/reactive_record' 3 | 4 | include ReactiveRecord 5 | 6 | describe 'ReactiveRecord' do 7 | before :all do 8 | # db setup 9 | @dbname = "reactive_record_test_#{Process.pid}" 10 | 11 | # N.B. all reactive_record methods are read only and so I believe 12 | # that it is valid to share a db connection. Nothing reactive record 13 | # does should mutate any global state 14 | system "createdb #{@dbname}" 15 | @db = PG.connect dbname: @dbname 16 | @db.exec File.read('spec/seed/database.sql') 17 | end 18 | 19 | after :all do 20 | # db teardown 21 | @db.close 22 | system "dropdb #{@dbname}" 23 | end 24 | 25 | context '#model_definition' do 26 | it 'generates an employee model def' do 27 | model_definition(@db, 'employees').should == 28 | <<-EOS.gsub(/^ {10}/, '') 29 | class Employees < ActiveRecord::Base 30 | set_table_name 'employees' 31 | set_primary_key :id 32 | validate :id, :name, :email, :start_date, presence: true 33 | validate :email, uniqueness: true 34 | validate { errors.add(:email, "Expected TODO") unless email =~ /.*@example.com/ } 35 | validate { errors.add(:start_date, "Expected TODO") unless start_date >= Date.parse(\"2009-04-13\") } 36 | validate { errors.add(:start_date, "Expected TODO") unless start_date <= Time.now.to_date + 365 } 37 | end 38 | EOS 39 | end 40 | 41 | it 'generates a project model def' do 42 | model_definition(@db, 'projects').should == 43 | <<-EOS.gsub(/^ {10}/, '') 44 | class Projects < ActiveRecord::Base 45 | set_table_name 'projects' 46 | set_primary_key :id 47 | validate :id, :name, presence: true 48 | validate :name, uniqueness: true 49 | end 50 | EOS 51 | end 52 | end 53 | 54 | context '#constraints' do 55 | it 'gathers all constraints on the employees table' do 56 | constraints(@db, 'employees').to_a.should == [ 57 | {"table_name"=>"employees", "constraint_name"=>"company_email", "constraint_src"=>"CHECK (((email)::text ~~ '%@example.com'::text))"}, 58 | {"table_name"=>"employees", "constraint_name"=>"employees_email_key", "constraint_src"=>"UNIQUE (email)"}, 59 | {"table_name"=>"employees", "constraint_name"=>"employees_pkey", "constraint_src"=>"PRIMARY KEY (id)"}, 60 | {"table_name"=>"employees", "constraint_name"=>"founding_date", "constraint_src"=>"CHECK ((start_date >= '2009-04-13'::date))"}, 61 | {"table_name"=>"employees", "constraint_name"=>"future_start_date", "constraint_src"=>"CHECK ((start_date <= (('now'::text)::date + 365)))"} 62 | ] 63 | end 64 | it 'gathers all constraints on the projects table' do 65 | constraints(@db, 'projects').to_a.should == [ 66 | {"table_name"=>"projects", "constraint_name"=>"projects_name_key", "constraint_src"=>"UNIQUE (name)"}, 67 | {"table_name"=>"projects", "constraint_name"=>"projects_pkey", "constraint_src"=>"PRIMARY KEY (id)"} 68 | ] 69 | end 70 | it 'gathers all constraints on the employees_projects table' do 71 | constraints(@db, 'employees_projects').to_a.should == [ 72 | { 73 | "table_name"=>"employees_projects", 74 | "constraint_name"=>"employees_projects_employee_id_fkey", 75 | "constraint_src"=>"FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE RESTRICT" 76 | }, 77 | { 78 | "table_name"=>"employees_projects", 79 | "constraint_name"=>"employees_projects_pkey", 80 | "constraint_src"=>"PRIMARY KEY (employee_id, project_id)" 81 | }, 82 | { 83 | "table_name"=>"employees_projects", 84 | "constraint_name"=>"employees_projects_project_id_fkey", 85 | "constraint_src"=>"FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE" 86 | } 87 | ] 88 | end 89 | end 90 | 91 | context '#generate_constraints' do 92 | it 'generates ruby code for employees constraints' do 93 | generate_constraints(@db, 'employees').should == [ 94 | "validate { errors.add(:email, \"Expected TODO\") unless email =~ /.*@example.com/ }", 95 | "validate { errors.add(:start_date, \"Expected TODO\") unless start_date >= Date.parse(\"2009-04-13\") }", 96 | "validate { errors.add(:start_date, \"Expected TODO\") unless start_date <= Time.now.to_date + 365 }", 97 | ] 98 | end 99 | end 100 | 101 | context '#unique_columns' do 102 | it 'returns email for employees' do 103 | unique_columns(@db, 'employees').should == ['email'] 104 | end 105 | end 106 | 107 | context '#cols_with_contype' do 108 | it 'identifies UNIQUE columns in database' do 109 | cols_with_contype(@db, 'employees', 'u').to_a.should == [{"column_name"=>"email", "conname"=>"employees_email_key"}, 110 | {"column_name"=>"name", "conname"=>"projects_name_key"}] 111 | end 112 | end 113 | 114 | context '#non_nullable_columns' do 115 | it 'identifies NOT NULL columns in employees' do 116 | non_nullable_columns(@db, 'employees').should == ['id', 'name', 'email', 'start_date'] 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/seed/database.sql: -------------------------------------------------------------------------------- 1 | -- create tables 2 | 3 | CREATE TABLE employees ( 4 | id SERIAL, 5 | name VARCHAR(255) NOT NULL, 6 | email VARCHAR(255) NOT NULL UNIQUE, 7 | start_date DATE NOT NULL, 8 | 9 | PRIMARY KEY (id), 10 | 11 | CONSTRAINT founding_date CHECK (start_date >= '2009-04-13'), 12 | CONSTRAINT future_start_date CHECK (start_date <= CURRENT_DATE + 365), 13 | CONSTRAINT company_email CHECK (email LIKE '%@example.com') 14 | ); 15 | 16 | CREATE TABLE projects ( 17 | id INTEGER NOT NULL, 18 | name VARCHAR(255) NOT NULL UNIQUE, 19 | 20 | PRIMARY KEY (id) 21 | ); 22 | 23 | CREATE TABLE employees_projects ( 24 | employee_id INTEGER REFERENCES employees ON DELETE RESTRICT, 25 | project_id INTEGER REFERENCES projects ON DELETE CASCADE, 26 | 27 | PRIMARY KEY (employee_id, project_id) 28 | ); 29 | 30 | -- seed data 31 | 32 | INSERT INTO employees (id, name, email, start_date) 33 | VALUES 34 | (1, 'Alfred Arnold', 'alfred@example.com', '2009-04-13'), 35 | (2, 'Benedict Burton', 'benedict@example.com', '2011-09-01'), 36 | (3, 'Cat Cams', 'cat@example.com', '2009-04-13'), 37 | (4, 'Duane Drummond', 'duane@example.com', '2009-04-13'), 38 | (5, 'Elizabeth Eggers', 'elizabeth@example.com', '2009-04-13'), 39 | (6, 'Fred Fitzgerald', 'fred@example.com', '2012-01-01'), 40 | (7, 'Greg Gruber', 'greg@example.com', '2013-01-07'), 41 | (8, 'Horatio Helms', 'horatio@example.com', '2012-01-01'), 42 | (9, 'Ian Ives', 'ian@example.com', '2009-04-13'), 43 | (10, 'Jan Jarvis', 'jan@example.com', '2013-01-28'), 44 | (11, 'Kevin Kelvin', 'kevin@example.com', '2009-04-13'), 45 | (12, 'Lucy Lemieux', 'lucy@example.com', '2009-04-13'); 46 | 47 | INSERT INTO projects (id, name) 48 | VALUES 49 | (1, 'Yoyodyne Inc.'), 50 | (2, 'Global Omni Mega Corp.'), 51 | (3, 'Murray''s Widgets Ltd.'), 52 | (4, 'Spatula City'), 53 | (5, 'Aperture Laboratories'); 54 | 55 | INSERT INTO employees_projects (employee_id, project_id) 56 | VALUES 57 | (1,1), 58 | (2,1), 59 | (3,2), 60 | (4,2), 61 | (5,3), 62 | (6,3), 63 | (7,4), 64 | (8,4), 65 | (9,5), 66 | (10,5), 67 | (11,5); 68 | --------------------------------------------------------------------------------