#****************************************************************************** #* Classes implementing a generic database model on top of plain text files #* stored in Perforce. #* #* Every record consists of fields/values and may have associated files. The #* data for each record is stored as both a job and a text file. The text #* file is always called /meta and any associated files are also #* stored alongside the record file. i.e. /file1 /file2. #* #* Each table name is a folder name and all records for that table are stored #* in text files under that folder. i.e. //record #* #* All column names must also be fields in the jobspec so you can search for #* records using "p4 jobs -e". NOTE: When creating your jobspec, make sure #* that all field names are in lowercase. Ruby likes method names to be #* lowercase and it fits better as well as reducing scope for errors. The #* one exception to this is the 'Job' field which Perforce doesn't like being #* renamed... #* #* The job version of any record contains only the latest version of that #* record for searching purposes. #* #* WARNING: This software is designed to have its OWN Perforce server to #* work with. DO NOT USE IT AGAINST AN EXISTING PERFORCE SERVER. #* #****************************************************************************** require "P4" #******************************************************************************* #* START OF CONFIGURATION SECTION #******************************************************************************* #------------------------------------------------------------------------------- #* Directory separator string for your platform #------------------------------------------------------------------------------- if ( RUBY_PLATFORM =~ /win32/i ) SEP = "\\" NL = "\r\n" else SEP = "/" NL = "\n" end #------------------------------------------------------------------------------- #* Specifies in depot syntax the depot path that is to be treated as the #* top of the database. This path MUST be mapped to the client root. #------------------------------------------------------------------------------- DEPOT_ROOT_PATH = "//depot" #******************************************************************************* #* END OF CONFIGURATION SECTION #******************************************************************************* #******************************************************************************* #* Class for creating and formatting record id's. Normally these are just #* of the form "/" #******************************************************************************* class P4RecId #--------------------------------------------------------------------------- # Class methods. #--------------------------------------------------------------------------- #--------------------------------------------------------------------------- # Get the next ID for the specified table #--------------------------------------------------------------------------- def P4RecId.next( p4, table ) val = p4.run_counter( table.name ).shift.to_i val += 1 p4.run_counter( table.name, val ) P4RecId.new( table.name, val ) end def P4RecId.new_from_job( p4, job ) ( table, seq ) = job.split( "/" ) P4RecId.new( table, seq ) end #--------------------------------------------------------------------------- # Instance methods #--------------------------------------------------------------------------- def initialize( tablename, seq ) @tablename = tablename if ( seq.kind_of?( String ) ) if ( seq =~ /^0*([1-9]\d+)$/ ) @seq = $1.to_i else @seq = seq.to_i end else @seq = seq end end attr_accessor :tablename, :seq #--------------------------------------------------------------------------- # Format the sequence number for printing #--------------------------------------------------------------------------- def seq_str sprintf( "%06d", @seq ) end #--------------------------------------------------------------------------- # String representation of an id #--------------------------------------------------------------------------- def to_s @tablename + "/" + seq_str() end end #******************************************************************************* # Class for representing files that are versioned in the depot. #******************************************************************************* class P4RecFile def initialize( p4, name, rec ) @p4 = p4 @name = name @rec = rec @exists = false @depot_path = nil @ws_path = nil end attr_reader :p4, :name, :rec #--------------------------------------------------------------------------- # Convert the depot path into a client path. Obviously this precludes # complex client maps #--------------------------------------------------------------------------- def ws_path return @ws_path if ( @ws_path ) root = p4.fetch_client[ "Root" ] path = depot_path.sub( DEPOT_ROOT_PATH, root ) @ws_path = path.gsub!( "/", SEP ) end #--------------------------------------------------------------------------- # Explicitly set the depot path for a file. Needed to allow old records # to be found in their old locations even if the storage map has since # changed. #--------------------------------------------------------------------------- def depot_path=( path ) @depot_path = path end #--------------------------------------------------------------------------- # Compute the depot path based on the table storage map and the id #--------------------------------------------------------------------------- def depot_path return @depot_path if @depot_path table_path = @rec.table.storage_map( @rec ).join( "/" ) @depot_path = [ DEPOT_ROOT_PATH, table_path, @rec.id.seq_str, @name ].join( "/" ) end #--------------------------------------------------------------------------- # Does the file exist? #--------------------------------------------------------------------------- def exists? @exists end #--------------------------------------------------------------------------- # The file definitely does exist #--------------------------------------------------------------------------- def exists=( bool ) @exists = bool end #--------------------------------------------------------------------------- # Open this file for add #--------------------------------------------------------------------------- def add if ( exists? ) raise( RuntimeError, "Can't open existing file for add", caller ) end p4.run_add( ws_path() ) end #--------------------------------------------------------------------------- # Open this file for edit #--------------------------------------------------------------------------- def edit if ( ! exists? ) raise( RuntimeError,"Can't open non-existent file for edit", caller) end p4.run_sync( depot_path() ) p4.run_edit( depot_path() ) end #--------------------------------------------------------------------------- # Open this file for delete #--------------------------------------------------------------------------- def delete if ( ! exists? ) raise( RuntimeError, "Can't delete non-existent file", caller ) end p4.run_sync( depot_path() ) p4.run_delete( depot_path() ) end #--------------------------------------------------------------------------- # Read the contents of the file (returns a String) #--------------------------------------------------------------------------- def read p4.run_sync( depot_path() ) f = File.open( ws_path, "r" ) buf = f.read f.close return buf end #--------------------------------------------------------------------------- # Write the contents of the file #--------------------------------------------------------------------------- def write( string ) p4.run_sync( depot_path() ) mkdir() File.open( ws_path, "w" ) { |f| f.write( string ) } end #--------------------------------------------------------------------------- # Create the directory for the workspace file #--------------------------------------------------------------------------- def mkdir dirs = ws_path.split( SEP ) dirs.pop dir = "" dirs.each do |d| dir += d + SEP Dir.mkdir( dir ) unless File.directory?( dir ) end end #--------------------------------------------------------------------------- # Render the filename as a string (we use depot syntax) #--------------------------------------------------------------------------- def to_s depot_file end end #******************************************************************************* #* Main record manipulation class. Handles the loading and saving of record #* as both jobs and files. #******************************************************************************* class P4Record #*************************************************************************** # Class methods #*************************************************************************** #--------------------------------------------------------------------------- # Load a record from its associated job #--------------------------------------------------------------------------- def P4Record.load( p4, table, id ) if ( ! P4Record.exists?( p4, id ) ) raise( RuntimeError, "Record #{id.to_s} does not exist", caller ) end rec = P4Record.new( p4, table, id ) rec.spec = p4.fetch_job( id.to_s ) if rec.spec.has_key?( "files" ) rec.spec[ "files" ].each do |file| name = file.sub( /.*\//, "" ).chomp f = P4RecFile.new( p4, name, rec ) f.exists = true f.depot_path = file.chomp rec.files[ name ] = f end end rec.exists = true return rec end #--------------------------------------------------------------------------- # Load all records matching a query expression #--------------------------------------------------------------------------- def P4Record.query( p4, table, expr ) expr = "job=#{table} & ( " + expr + " )" p4.run_jobs( "-e", expr ).collect do |job| rec = P4Record.new(p4, table, P4RecId.new_from_job( p4,job["Job"])) rec.spec = job rec.spec[ "files" ].each do |file| name = file.sub( /.*\//, "" ).chomp f = P4RecFile.new( p4, name, rec.id ) f.exists = true f.depot_path = file rec.files[ name ] = f end rec.exists = true rec end end #--------------------------------------------------------------------------- # Create a new record in the given table #--------------------------------------------------------------------------- def P4Record.create( p4, table ) id = P4RecId.next( p4, table ) rec = P4Record.new( p4, table, id ) rec.spec = p4.fetch_job( id.to_s ) return rec end #--------------------------------------------------------------------------- # Test for record existence #--------------------------------------------------------------------------- def P4Record.exists?( p4, id ) jobs = p4.run_jobs( "-e", "job=" + id.to_s ) return jobs.length > 0 end #--------------------------------------------------------------------------- # Constructor: DON'T USE NEW DIRECTLY, CALL create/load. #--------------------------------------------------------------------------- def initialize( p4, table, id ) @p4 = p4 @id = id @seq = id.seq @table = table @spec = nil @exists = false @files = Hash.new @files[ "meta" ] = P4RecFile.new( @p4, "meta", self ) end #*************************************************************************** # Instance methods #*************************************************************************** attr_accessor :p4, :id, :table, :seq, :meta, :spec attr_reader :files #--------------------------------------------------------------------------- # Does the record exist? #--------------------------------------------------------------------------- def exists? return @exists end #--------------------------------------------------------------------------- # The record definitely does exist #--------------------------------------------------------------------------- def exists=( bool ) @exists = bool end #--------------------------------------------------------------------------- # Get a file handle by name (not the depot path) #--------------------------------------------------------------------------- def get_file( name ) return @files[ name ] if @files.has_key?( name ) raise( RuntimeError, "Record contains no file called #{name}", caller ) end #--------------------------------------------------------------------------- # Get the meta file #--------------------------------------------------------------------------- def metafile get_file( "meta" ) end #--------------------------------------------------------------------------- # Iterate over the files in the record #--------------------------------------------------------------------------- def each_file @files.each_value { |f| yield( f ) } end #--------------------------------------------------------------------------- # Add a new file attachment to this record #--------------------------------------------------------------------------- def add_file( name ) if ( name == "meta" ) raise( RuntimeError, "The meta file already exists.", caller ) end nfile = P4RecFile.new( p4, name, self ) nfile.add @files[ "name" ] = nfile end #--------------------------------------------------------------------------- # Remove a file attachment #--------------------------------------------------------------------------- def rm_file( file ) if ( file == "meta" ) raise( RuntimeError, "You can't delete the meta file", caller ) end f = get_file( file ) f.delete @files.delete( file ) end #--------------------------------------------------------------------------- # Abandon all edits to this record #--------------------------------------------------------------------------- def abandon each_file do |f| fs = p4.run_fstat( f.ws_path ) if ( fs && fs[ "action" ] == "add" ) File.unlink( f.ws_path ) end p4.run_revert( f.depot_path ) end end #--------------------------------------------------------------------------- # Get the list of files attached to this record in depot syntax #--------------------------------------------------------------------------- def file_list @files.values.collect do |f| f.depot_path end.compact.join( "\n" ) end #--------------------------------------------------------------------------- # Save changes #--------------------------------------------------------------------------- def save( desc ) # First rewrite the files list in case it's been modified @spec[ "files" ] = file_list # Next update the job with the values in the spec p4.save_job( @spec ) # Now update the metafile with the job -o output if ( exists? ) metafile.edit else metafile.add end metafile.write( p4.format_job( @spec ) ) # Now submit change = p4.fetch_change change[ "Description" ] = desc p4.run_submit( change ) @exists = true end #--------------------------------------------------------------------------- # Delete a record #--------------------------------------------------------------------------- def delete( desc ) if ( ! exists? ) raise( RuntimeError, "Can't delete. Record doesn't exist.", caller ) end @files.values.each { |f| f.delete } change = p4.fetch_change change[ "Description" ] = desc p4.run_submit( change ) p4.run_job( "-d", @spec[ "Job" ] ) @files = Hash.new @spec[ "files" ] = Array.new @exists = false end #--------------------------------------------------------------------------- # Obliterate a record. #--------------------------------------------------------------------------- def obliterate if ( ! exists? ) raise( RuntimeError, "Can't oblit. Record doesn't exist.", caller ) end args = @files.values.collect { |f| f.depot_path } p4.at_exception_level( P4::RAISE_ERRORS ) do p4.run_sync( args.collect { |a| a += "#none" } ) end p4.run_obliterate( "-y", args ) p4.run_job( "-d", @spec[ "Job" ] ) @spec[ "files" ] = Array.new @files = Hash.new @exists = false end #--------------------------------------------------------------------------- # Allow direct access to the fields in the jobspec by making them virtual # method names of the form _() #--------------------------------------------------------------------------- def method_missing( meth, *args ) meth = meth.to_s raise if ( meth[0..0] != "_" ) meth = meth[ 1..-1 ] if ( meth =~ /^(.*)=$/ ) meth = $1 @spec[ meth ] = args.shift elsif ( args.length == 0 && @spec.has_key?( meth ) ) @spec[ meth ] else "" end end end #******************************************************************************* #* Base class for all table classes. #******************************************************************************* class P4Table def initialize( p4, name ) @p4 = p4 @name = name end attr_reader :p4, :name def new_record P4Record.create( p4, self ) end def load_record( seq ) P4Record.load( p4, self, P4RecId.new( @name, seq ) ) end def query( expr ) P4Record.query( p4, name, expr ) end #--------------------------------------------------------------------------- # Return an array of directory components representing the path where # the files for this object should be stored. This allows you to tweak # the directory structure your files are stored in so you don't end up # with 1000's of files in a single directory. Don't edit this method # though. Derive your own class and override it there. #--------------------------------------------------------------------------- def storage_map( rec ) return [ rec.table.name ] end end #******************************************************************************* # Built-in test harness - only executed if this file is the main ruby # script rather than a module being loaded. This script creates a temporary # Perforce repository and client workspace in two subdirectories of the # current working directory. You can safely remove these directories once # the test has been run. #******************************************************************************* if $0 == __FILE__ def init_jobspec( p4 ) js = <