lib/rhubarb/rhubarb.rb in rhubarb-0.2.0 vs lib/rhubarb/rhubarb.rb in rhubarb-0.2.1

- old
+ new

@@ -11,535 +11,12 @@ # # http://www.apache.org/licenses/LICENSE-2.0 require 'rubygems' require 'set' -require 'time' require 'sqlite3' - -module Rhubarb - -module SQLBUtil - def self.timestamp(tm=nil) - tm ||= Time.now.utc - (tm.tv_sec * 1000000) + tm.tv_usec - end -end - - -module Persistence - class DbCollection < Hash - alias orig_set []= - - def []=(k,v) - v.results_as_hash = true if v - v.type_translation = true if v - orig_set(k,v) - end - end - - @@dbs = DbCollection.new - - def self.open(filename, which=:default) - dbs[which] = SQLite3::Database.new(filename) - end - - def self.close(which=:default) - if dbs[which] - dbs[which].close - dbs.delete(which) - end - end - - def self.db - dbs[:default] - end - - def self.db=(d) - dbs[:default] = d - end - - def self.dbs - @@dbs - end -end - -class Column - attr_reader :name - - def initialize(name, kind, quals) - @name, @kind = name, kind - @quals = quals.map {|x| x.to_s.gsub("_", " ") if x.class == Symbol} - @quals.map - end - - def to_s - qualifiers = @quals.join(" ") - if qualifiers == "" - "#@name #@kind" - else - "#@name #@kind #{qualifiers}" - end - end -end - -class Reference - attr_reader :referent, :column, :options - - # Creates a new Reference object, modeling a foreign-key relationship to another table. +klass+ is a class that includes Persisting; +options+ is a hash of options, which include - # +:column => + _name_:: specifies the name of the column to reference in +klass+ (defaults to row id) - # +:on_delete => :cascade+:: specifies that deleting the referenced row in +klass+ will delete all rows referencing that row through this reference - def initialize(klass, options={}) - @referent = klass - @options = options - @options[:column] ||= "row_id" - @column = options[:column] - end - - def to_s - trigger = "" - trigger = " on delete cascade" if options[:on_delete] == :cascade - "references #{@referent}(#{@column})#{trigger}" - end - - def managed_ref? - # XXX? - return false if referent.class == String - referent.ancestors.include? Persisting - end -end - - -# Methods mixed in to the class object of a persisting class -module PersistingClassMixins - # Returns the name of the database table modeled by this class. - # Defaults to the name of the class (sans module names) - def table_name - @table_name ||= self.name.split("::").pop.downcase - end - - # Enables setting the table name to a custom name - def declare_table_name(nm) - @table_name = nm - end - - # Models a foreign-key relationship. +options+ is a hash of options, which include - # +:column => + _name_:: specifies the name of the column to reference in +klass+ (defaults to row id) - # +:on_delete => :cascade+:: specifies that deleting the referenced row in +klass+ will delete all rows referencing that row through this reference - def references(table, options={}) - Reference.new(table, options) - end - - # Models a CHECK constraint. - def check(condition) - "check (#{condition})" - end - - # Returns an object corresponding to the row with the given ID, or +nil+ if no such row exists. - def find(id) - tup = self.find_tuple(id) - return self.new(tup) if tup - nil - end - - alias find_by_id find - - def find_by(arg_hash) - arg_hash = arg_hash.dup - valid_cols = self.colnames.intersection arg_hash.keys - select_criteria = valid_cols.map {|col| "#{col.to_s} = #{col.inspect}"}.join(" AND ") - arg_hash.each {|k,v| arg_hash[k] = v.row_id if v.respond_to? :row_id} - - self.db.execute("select * from #{table_name} where #{select_criteria} order by row_id", arg_hash).map {|tup| self.new(tup) } - end - - # args contains the following keys - # * :group_by maps to a list of columns to group by (mandatory) - # * :select_by maps to a hash mapping from column symbols to values (optional) - # * :version maps to some version to be considered "current" for the purposes of this query; that is, all rows later than the "current" version will be disregarded (optional, defaults to latest version) - def find_freshest(args) - args = args.dup - - args[:version] ||= SQLBUtil::timestamp - args[:select_by] ||= {} - - query_params = {} - query_params[:version] = args[:version] - - select_clauses = ["created <= :version"] - - valid_cols = self.colnames.intersection args[:select_by].keys - - valid_cols.map do |col| - select_clauses << "#{col.to_s} = #{col.inspect}" - val = args[:select_by][col] - val = val.row_id if val.respond_to? :row_id - query_params[col] = val - end - - group_by_clause = "GROUP BY " + args[:group_by].join(", ") - where_clause = "WHERE " + select_clauses.join(" AND ") - projection = self.colnames - [:created] - join_clause = projection.map do |column| - "__fresh.#{column} = __freshest.#{column}" - end - - projection << "MAX(created) AS __current_version" - join_clause << "__fresh.__current_version = __freshest.created" - - query = " -SELECT __freshest.* FROM ( - SELECT #{projection.to_a.join(', ')} FROM ( - SELECT * from #{table_name} #{where_clause} - ) #{group_by_clause} -) as __fresh INNER JOIN #{table_name} as __freshest ON - #{join_clause.join(' AND ')} - ORDER BY row_id -" - - self.db.execute(query, query_params).map {|tup| self.new(tup) } - end - - # Does what it says on the tin. Since this will allocate an object for each row, it isn't recomended for huge tables. - def find_all - self.db.execute("SELECT * from #{table_name}").map {|tup| self.new(tup)} - end - - def delete_all - self.db.execute("DELETE from #{table_name}") - end - - # Declares a query method named +name+ and adds it to this class. The query method returns a list of objects corresponding to the rows returned by executing "+SELECT * FROM+ _table_ +WHERE+ _query_" on the database. - def declare_query(name, query) - klass = (class << self; self end) - klass.class_eval do - define_method name.to_s do |*args| - # handle reference parameters - args = args.map {|x| (x.row_id if x.class.ancestors.include? Persisting) or x} - - res = self.db.execute("select * from #{table_name} where #{query}", args) - res.map {|row| self.new(row)} - end - end - end - - # Declares a custom query method named +name+, and adds it to this class. The custom query method returns a list of objects corresponding to the rows returned by executing +query+ on the database. +query+ should select all fields (with +SELECT *+). If +query+ includes the string +\_\_TABLE\_\_+, it will be expanded to the table name. Typically, you will want to use +declare\_query+ instead; this method is most useful for self-joins. - def declare_custom_query(name, query) - klass = (class << self; self end) - klass.class_eval do - define_method name.to_s do |*args| - # handle reference parameters - args = args.map {|x| (x.row_id if x.class.ancestors.include? Persisting) or x} - - res = self.db.execute(query.gsub("__TABLE__", "#{self.table_name}"), args) - # XXX: should freshen each row? - res.map {|row| self.new(row) } - end - end - end - - def declare_index_on(*fields) - @creation_callbacks << Proc.new do - idx_name = "idx_#{self.table_name}__#{fields.join('__')}__#{@creation_callbacks.size}" - creation_cmd = "create index #{idx_name} on #{self.table_name} (#{fields.join(', ')})" - self.db.execute(creation_cmd) - end if fields.size > 0 - end - - # Adds a column named +cname+ to this table declaration, and adds the following methods to the class: - # * accessors for +cname+, called +cname+ and +cname=+ - # * +find\_by\_cname+ and +find\_first\_by\_cname+ methods, which return a list of rows and the first row that have the given value for +cname+, respectively - # If the column references a column in another table (given via a +references(...)+ argument to +quals+), then add triggers to the database to ensure referential integrity and cascade-on-delete (if specified) - def declare_column(cname, kind, *quals) - ensure_accessors - - find_method_name = "find_by_#{cname}".to_sym - find_first_method_name = "find_first_by_#{cname}".to_sym - - get_method_name = "#{cname}".to_sym - set_method_name = "#{cname}=".to_sym - - # does this column reference another table? - rf = quals.find {|q| q.class == Reference} - if rf - self.refs[cname] = rf - end - - # add a find for this column (a class method) - klass = (class << self; self end) - klass.class_eval do - define_method find_method_name do |arg| - res = self.db.execute("select * from #{table_name} where #{cname} = ?", arg) - res.map {|row| self.new(row)} - end - - define_method find_first_method_name do |arg| - res = self.db.execute("select * from #{table_name} where #{cname} = ?", arg) - return self.new(res[0]) if res.size > 0 - nil - end - end - - self.colnames.merge([cname]) - self.columns << Column.new(cname, kind, quals) - - # add accessors - define_method get_method_name do - freshen - return @tuple["#{cname}"] if @tuple - nil - end - - if not rf - define_method set_method_name do |arg| - @tuple["#{cname}"] = arg - update cname, arg - end - else - # this column references another table; create a set - # method that can handle either row objects or row IDs - define_method set_method_name do |arg| - freshen - - arg_id = nil - - if arg.class == Fixnum - arg_id = arg - arg = rf.referent.find arg_id - else - arg_id = arg.row_id - end - @tuple["#{cname}"] = arg - - update cname, arg_id - end - - # Finally, add appropriate triggers to ensure referential integrity. - # If rf has an on_delete trigger, also add the necessary - # triggers to cascade deletes. - # Note that we do not support update triggers, since the API does - # not expose the capacity to change row IDs. - - self.creation_callbacks << Proc.new do - @ccount ||= 0 - - insert_trigger_name = "ri_insert_#{self.table_name}_#{@ccount}_#{rf.referent.table_name}" - delete_trigger_name = "ri_delete_#{self.table_name}_#{@ccount}_#{rf.referent.table_name}" - - self.db.execute_batch("CREATE TRIGGER #{insert_trigger_name} BEFORE INSERT ON \"#{self.table_name}\" WHEN new.\"#{cname}\" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM \"#{rf.referent.table_name}\" WHERE new.\"#{cname}\" == \"#{rf.column}\") BEGIN SELECT RAISE(ABORT, 'constraint #{insert_trigger_name} (#{rf.referent.table_name} missing foreign key row) failed'); END;") - - self.db.execute_batch("CREATE TRIGGER #{delete_trigger_name} BEFORE DELETE ON \"#{rf.referent.table_name}\" WHEN EXISTS (SELECT 1 FROM \"#{self.table_name}\" WHERE old.\"#{rf.column}\" == \"#{cname}\") BEGIN DELETE FROM \"#{self.table_name}\" WHERE \"#{cname}\" = old.\"#{rf.column}\"; END;") if rf.options[:on_delete] == :cascade - - @ccount = @ccount + 1 - end - end - end - - # Declares a constraint. Only check constraints are supported; see - # the check method. - def declare_constraint(cname, kind, *details) - ensure_accessors - info = details.join(" ") - @constraints << "constraint #{cname} #{kind} #{info}" - end - - # Creates a new row in the table with the supplied column values. - # May throw a SQLite3::SQLException. - def create(*args) - new_row = args[0] - new_row[:created] = new_row[:updated] = SQLBUtil::timestamp - - cols = colnames.intersection new_row.keys - colspec = (cols.map {|col| col.to_s}).join(", ") - valspec = (cols.map {|col| col.inspect}).join(", ") - res = nil - - # resolve any references in the args - new_row.each do |k,v| - new_row[k] = v.row_id if v.class.ancestors.include? Persisting - end - - self.db.transaction do |db| - stmt = "insert into #{table_name} (#{colspec}) values (#{valspec})" -# p stmt - db.execute(stmt, new_row) - res = find(db.last_insert_row_id) - end - res - end - - # Returns a string consisting of the DDL statement to create a table - # corresponding to this class. - def table_decl - cols = columns.join(", ") - consts = constraints.join(", ") - if consts.size > 0 - "create table #{table_name} (#{cols}, #{consts});" - else - "create table #{table_name} (#{cols});" - end - end - - # Creates a table in the database corresponding to this class. - def create_table(dbkey=:default) - self.db ||= Persistence::dbs[dbkey] - self.db.execute(table_decl) - @creation_callbacks.each {|func| func.call} - end - - def db - @db || Persistence::db - end - - def db=(d) - @db = d - end - - # Ensure that all the necessary accessors on our class instance are defined - # and that all metaclass fields have the appropriate values - def ensure_accessors - # Define singleton accessors - if not self.respond_to? :columns - class << self - # Arrays of columns, column names, and column constraints. - # Note that colnames does not contain id, created, or updated. - # The API purposefully does not expose the ability to create a - # row with a given id, and created and updated values are - # maintained automatically by the API. - attr_accessor :columns, :colnames, :constraints, :dirtied, :refs, :creation_callbacks - end - end - - # Ensure singleton fields are initialized - self.columns ||= [Column.new(:row_id, :integer, [:primary_key]), Column.new(:created, :integer, []), Column.new(:updated, :integer, [])] - self.colnames ||= Set.new [:created, :updated] - self.constraints ||= [] - self.dirtied ||= {} - self.refs ||= {} - self.creation_callbacks ||= [] - end - - # Returns the number of rows in the table backing this class - def count - result = self.db.execute("select count(row_id) from #{table_name}")[0] - result[0].to_i - end - - def find_tuple(id) - res = self.db.execute("select * from #{table_name} where row_id = ?", id) - if res.size == 0 - nil - else - res[0] - end - end -end - -module Persisting - def self.included(other) - class << other - include PersistingClassMixins - end - - other.class_eval do - attr_reader :row_id - attr_reader :created - attr_reader :updated - end - end - - def db - self.class.db - end - - # Returns true if the row backing this object has been deleted from the database - def deleted? - freshen - not @tuple - end - - # Initializes a new instance backed by a tuple of values. Do not call this directly. - # Create new instances with the create or find methods. - def initialize(tup) - @backed = true - @tuple = tup - mark_fresh - @row_id = @tuple["row_id"] - @created = @tuple["created"] - @updated = @tuple["updated"] - resolve_referents - self.class.dirtied[@row_id] ||= @expired_after - self - end - - # Deletes the row corresponding to this object from the database; - # invalidates =self= and any other objects backed by this row - def delete - self.db.execute("delete from #{self.class.table_name} where row_id = ?", @row_id) - mark_dirty - @tuple = nil - @row_id = nil - end - - ## Begin private methods - - private - - # Fetches updated attribute values from the database if necessary - def freshen - if needs_refresh? - @tuple = self.class.find_tuple(@row_id) - if @tuple - @updated = @tuple["updated"] - else - @row_id = @updated = @created = nil - end - mark_fresh - resolve_referents - end - end - - # True if the underlying row in the database is inconsistent with the state - # of this object, whether because the row has changed, or because this object has no row id - def needs_refresh? - if not @row_id - @tuple != nil - else - @expired_after < self.class.dirtied[@row_id] - end - end - - # Mark this row as dirty so that any other objects backed by this row will - # update from the database before their attributes are inspected - def mark_dirty - self.class.dirtied[@row_id] = SQLBUtil::timestamp - end - - # Mark this row as consistent with the underlying database as of now - def mark_fresh - @expired_after = SQLBUtil::timestamp - end - - # Helper method to update the row in the database when one of our fields changes - def update(attr_name, value) - mark_dirty - self.db.execute("update #{self.class.table_name} set #{attr_name} = ?, updated = ? where row_id = ?", value, SQLBUtil::timestamp, @row_id) - end - - # Resolve any fields that reference other tables, replacing row ids with referred objects - def resolve_referents - refs = self.class.refs - - refs.each do |c,r| - c = c.to_s - if r.referent == self.class and @tuple[c] == row_id - @tuple[c] = self - else - row = r.referent.find @tuple[c] - @tuple[c] = row if row - end - end - end - -end - -end +require 'rhubarb/util' +require 'rhubarb/persistence' +require 'rhubarb/column' +require 'rhubarb/reference' +require 'rhubarb/classmixins' +require 'rhubarb/persisting'