#****************************************************************************** #* 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 /record 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 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 #******************************************************************************* #------------------------------------------------------------------------------- # This class just provides two globally accessible Perforce client instances # without the use of global variables. One P4 client runs in tagged mode # and one in untagged mode. Most of the queries need tagged mode, but when # we want to run a "p4 job -o" to archive the job, we need it in plain text. #------------------------------------------------------------------------------- class P4Global @@p4t = P4.new @@p4u = P4.new @@p4t.parse_forms @@p4t.exception_level = 1 #--------------------------------------------------------------------------- # Set up the Perforce environment #--------------------------------------------------------------------------- def P4Global.p4user=( user ) @@p4t.user = user @@p4u.user = user end def P4Global.p4port=( port ) @@p4t.port = port @@p4u.port = port end def P4Global.p4passwd=( password ) @@p4t.password = password @@p4u.password = password end def P4Global.p4client=( client ) @@p4t.client = client @@p4u.client = client end def P4Global.connect @@p4t.connect @@p4u.connect end def P4Global.disconnect @@p4t.disconnect @@p4u.disconnect end def P4Global.tagged @@p4t end def P4Global.plain @@p4u end def P4Global.debug=( level ) @@p4t.debug = level @@p4u.debug = level end end #******************************************************************************* #* 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( table ) p4 = P4Global.tagged val = p4.run_counter( table.name ).shift.to_i val += 1 p4.run_counter( table.name, val ) P4RecId.new( table, val ) end def P4RecId.new_from_job( job ) ( table, seq ) = job.split( "/" ) P4RecId.new( P4Table.new( table ), seq ) end #--------------------------------------------------------------------------- # Instance methods #--------------------------------------------------------------------------- def initialize( table, seq ) @table = table 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 :table, :seq #--------------------------------------------------------------------------- # Format the sequence number for printing #--------------------------------------------------------------------------- def seq_str sprintf( "%06d", @seq ) end #--------------------------------------------------------------------------- # String representation of an id #--------------------------------------------------------------------------- def to_s @table.name + "/" + seq_str() end end #******************************************************************************* # Class for representing files that are versioned in the depot. #******************************************************************************* class P4RecFile def initialize( name, id ) @name = name @id = id @exists = false @depot_path = nil @ws_path = nil end attr_reader :name, :id #--------------------------------------------------------------------------- # Convert the depot path into a client path. Obviously this precludes # complex client maps #--------------------------------------------------------------------------- def ws_path return @ws_path if ( @ws_path ) root = P4Global.tagged.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 = @id.table.storage_map( @id ).join( "/" ) @depot_path = [ DEPOT_ROOT_PATH, table_path, @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 P4Global.tagged.run_sync P4Global.tagged.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 P4Global.tagged.run_sync P4Global.tagged.run_edit( depot_path() ) end #--------------------------------------------------------------------------- # Open this file for delete #--------------------------------------------------------------------------- def delete if ( ! exists? ) raise( RuntimeError, "Can't delete non-existent file", caller ) end P4Global.tagged.run_sync P4Global.tagged.run_delete( depot_path() ) end #--------------------------------------------------------------------------- # Read the contents of the file (returns a String) #--------------------------------------------------------------------------- def read P4Global.tagged.run_sync f = File.open( ws_path, "r" ) buf = f.read f.close return buf end #--------------------------------------------------------------------------- # Write the contents of the file #--------------------------------------------------------------------------- def write( string ) P4Global.tagged.run_sync mkdir File.open( ws_path, "w" ) { |f| f.write( string ) } end #--------------------------------------------------------------------------- # Create the directory for the workspace file #--------------------------------------------------------------------------- def mkdir P4Global.tagged.run_sync 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( id ) if ( ! P4Record.exists?( id ) ) raise( RuntimeError, "Record #{id.to_s} does not exist", caller ) end rec = P4Record.new( id ) rec.spec = P4Global.tagged.fetch_job( id.to_s ) if rec.spec.has_key?( "files" ) rec.spec[ "files" ].each do |file| name = file.sub( ".*/", "" ).chomp f = P4RecFile.new( name, id ) 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( table, expr ) expr = "job=#{table} & ( " + expr + " )" P4Global.tagged.run_jobs( "-e", expr ).collect do |job| rec = P4Record.new( P4RecId.new_from_job( job[ "job" ] ) ) rec.spec = job rec.spec[ "files" ].each do |file| name = file.sub( ".*/", "" ).chomp f = P4RecFile.new( name, 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( table ) id = P4RecId.next( table ) rec = P4Record.new( id ) rec.spec = P4Global.tagged.fetch_job( id.to_s ) return rec end #--------------------------------------------------------------------------- # Test for record existence #--------------------------------------------------------------------------- def P4Record.exists?( id ) jobs = P4Global.tagged.run_jobs( "-e", "job=" + id.to_s ) return jobs.length > 0 end #--------------------------------------------------------------------------- # Constructor: DON'T USE NEW DIRECTLY, CALL create/load. #--------------------------------------------------------------------------- def initialize( id ) @id = id @table = id.table @seq = id.seq @spec = nil @exists = false @files = Hash.new @files[ "meta" ] = P4RecFile.new( "meta", @id ) end #*************************************************************************** # Instance methods #*************************************************************************** attr_accessor :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( name, @id ) 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 p4 = P4Global.tagged 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 P4Global.tagged.save_job( @spec ) # Now update the metafile with the job -o output if ( exists? ) metafile.edit else metafile.add end metafile.mkdir metafile.write( P4Global.plain.fetch_job( @id.to_s ) ) # Now submit change = P4Global.tagged.fetch_change change[ "Description" ] = desc P4Global.tagged.submit_spec( 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 = P4Global.tagged.fetch_change change[ "Description" ] = desc P4Global.tagged.submit_spec( change ) P4Global.tagged.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 } P4Global.tagged.exception_level = 1 P4Global.tagged.run_sync( args.collect { |a| a += "#none" } ) P4Global.tagged.exception_level = 2 P4Global.tagged.run_obliterate( "-y", args ) P4Global.tagged.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( name ) @name = name end attr_reader :name def new_record P4Record.create( self ) end def load_record( seq ) P4Record.load( P4RecId.new( self, seq ) ) end def query( expr ) P4Record.query( 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( id ) return [ id.table.name ] end end