#--
#*******************************************************************************
#++
# = Introduction
#
# 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 <id>/record and any associated files are also
# stored alongside the record file. i.e. <id>/file1 <id>/file2.
#
# Each table name is a folder name and all records for that table are stored
# in text files under that folder. i.e. <table>/<id>/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. <b>DO NOT USE IT AGAINST AN EXISTING PERFORCE
# SERVER.</b>
#
# For a quick start, take a look at the P4Table class as all your tables
# should be derived from it and the Jobspec rules below.
#
# = Jobspec Rules
#
# When creating your jobspec observe the following rules for any chance of
# success.
#
# * All fieldnames *must* be lowercase
# * The jobspec must contain a field called "job"
# * The jobspec *must* contain a field called "files" of type "text"
# * Make most fields optional - especially if you have more than one table
# * Do not use invalid preset values in your jobspec. i.e. a field which
# may only contain certain values but is deliberately initialised to an
# invalid value to force users to change it. P4/Ruby can't handle
# jobspecs like this at the moment.
#
# = Jobspec Example
#
# Below is the jobspec used for a FAX tracking application based on P4Ruby
# which should give you some idea of the possibilities.
#
# # A Perforce Job Spec Specification.
# #
# # Updating this form can be dangerous!
# # See 'p4 help jobspec' for proper directions.
#
# Fields:
# 101 job word 32 required
# 102 status select 10 required
# 103 owner word 32 required
# 104 modified_date date 20 always
# 105 note text 0 optional
# 106 sender line 32 optional
# 107 fax_number line 32 optional
# 108 files text 80 optional
# 109 date_received date 20 once
# 110 date_closed date 20 optional
#
# Values:
# status open/closed
#
# Presets:
# status open
# owner $user
# modified_date $now
# date_received $now
#
# Comments:
#
#--
#*******************************************************************************
#++
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.
#
# This class is also the reason why P4Table is not thread safe as all of the
# other classes that execute commands use it directly. I wanted to avoid
# excessive passing of P4 objects but in the end I'll probably have to go
# that way.
#
class P4Global
@@p4t = P4.new
@@p4u = P4.new
@@p4t.parse_forms
@@p4t.exception_level = 1
#---------------------------------------------------------------------------
# Set up the Perforce environment
#---------------------------------------------------------------------------
# Set the username for both tagged and untagged mode
def P4Global.p4user=( user )
@@p4t.user = user
@@p4u.user = user
end
# Set the port for both tagged and untagged mode
def P4Global.p4port=( port )
@@p4t.port = port
@@p4u.port = port
end
# Set the password for both tagged and untagged mode
def P4Global.p4passwd=( password )
@@p4t.password = password
@@p4u.password = password
end
# Set the clientspec for both tagged and untagged mode
def P4Global.p4client=( client )
@@p4t.client = client
@@p4u.client = client
end
# Connect both tagged and untagged clients to the server
def P4Global.connect
@@p4t.connect
@@p4u.connect
end
# Disconnect both tagged and untagged mode clients
def P4Global.disconnect
@@p4t.disconnect
@@p4u.disconnect
end
# Returns the tagged mode client
def P4Global.tagged
@@p4t
end
# Returns the untagged mode client
def P4Global.plain
@@p4u
end
# Sets the debug level for both tagged and untagged mode (2 is good)
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 "<tablename>/<nnnnnn>"
#
class P4RecId
#---------------------------------------------------------------------------
# Class methods.
#---------------------------------------------------------------------------
#
# Get the next ID for the specified table using
#
# 1. p4 counter <table>
# 2. p4 counter <table> = <n+1>
#
# Yes, this has a race condition, but there's no atomic get and set
# operation for Perforce counters.
#
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
#
# Instantiate a new P4RecID from the job name ( "<table>/<id>" ). Used
# to load existing records.
#
def P4RecId.new_from_job( job )
( table, seq ) = job.split( "/" )
P4RecId.new( P4Table.new( table ), seq )
end
#
# Construct a new RecID for the given table name and sequence number
#
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
#---------------------------------------------------------------------------
# Instance methods
#---------------------------------------------------------------------------
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. Files can
# be either (a) the meta file containing the metadata about the record
# (basically a "p4 job -o <recid>") or they can be other files which are
# attached to the record.
#
class P4RecFile
#
# Construct a new P4RecFile object based on the supplied filename and
# RecId.
#
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 as we don't want to reimplement Perforce mappings
# in Ruby.
#
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
#
# Set the existence flag for a file explicitly
#
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 records
# as both jobs and files.
#
class P4Record
#***************************************************************************
# Class methods
#***************************************************************************
#
# Load a record from its associated job. Use to load existing records
# rather than constructing new ones. Raises a RuntimeError if the
# record does not exist.
#
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. Uses a dynamically built
# "p4 jobs -e" expression to identify the matching records and then
# loads each record. Returns an array of P4Record objects.
#
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. Returns the skeletal record
# populated with the default values from the jobspec for editing.
#
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
attr_accessor :id, :table, :seq, :meta, :spec
attr_reader :files
#
# Test for record existence
#
def exists?
return @exists
end
#
# Explicitly set record existence (or otherwise)
#
def exists=( bool )
@exists = bool
end
#
# Get a file handle by name (not the depot path). Returns a P4RecFile
# object.
#
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 specifically. Just shorthand.
#
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. Use with care: it reverts the
# files that are open for add/edit/delete etc. but it doesn't
# reload the record from the job - it may be a new record. You
# should either discard the record or reload it yourself after
# calling abandon().
#
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
#
# Update the record. Updates the job and then archives the job into the
# meta file and saves any attached files. Provide the description you'd
# like to see attached to the change
#
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. Deletes the files and the job.
#
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. Does exactly what it says on the tin.
#
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 _<field>()
#
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. All user defined tables should be derived
# from this class. At the bare minimum you should provide an initialize()
# that removes the need for the table name to be specified at construction.
# Something like:
#
# class MyTable < P4Table
# def initialize
# super( "mytable" )
# end
# end
#
# You should also make sure that you are handling P4Exceptions to trap and
# report Perforce errors if they occur.
#
class P4Table
#
# Construct a new P4Table object for the named table
#
def initialize( name )
@name = name
end
attr_reader :name
#
# Add a new record to the table. Returns the record for editing. Note
# the record is not saved until you call P4Record#save
#
def new_record
P4Record.create( self )
end
#
# Load an existing record from the repository
#
def load_record( seq )
P4Record.load( P4RecId.new( self, seq ) )
end
#
# Search for matching records.
#
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.
#
# It is intended that you override this method in your derived classes.
# Note that you can change the storage map for a table at any time
# without affecting existing records. This is because the file paths
# for existing records are stored in the job and only the file paths
# for new records will be computed using storage_map()
#
# For example, to group records by the year and month in which they
# were created you could use this:
#
# def storage_map( id )
# Time.now.to_a[4,2].reverse + [ id.table.name ]
# end
#
def storage_map( id )
return [ id.table.name ]
end
end
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #3 | 6437 | Tony Smith |
Update P4Ruby Library scripts to support Perforce P4Ruby 2007.3 rather than my old public depot P4Ruby. |
||
| #2 | 4678 | Tony Smith |
Update p4table.rb to use the new spec parsing and formatting features of P4Ruby. This means we now only need one Perforce client instance to handle specs in both hash form and text form. |
||
| #1 | 4677 | Tony Smith |
Move P4table.rb to the P4Ruby library and rename it to p4table.rb instead. There's an update coming so this is just the move. |
||
| //guest/tony_smith/perforce/utils/P4table.rb | |||||
| #2 | 2396 | Tony Smith |
Add RDoc documentation for P4Table.rb. Very pretty. |
||
| #1 | 2393 | Tony Smith |
Publish P4Table Ruby module that allows you to treat a Perforce repository as a sort of relational database with inbuilt versioning of all its records. Very simple, but effective. |
||