Initial commit.
authorMahlon E. Smith <mahlon@martini.nu>
Thu, 05 Nov 2015 10:34:15 -0800
changeset 0 3cc90e88c6ab
child 1 60b437614edd
Initial commit.
.document
.gems
.gitignore
.hgignore
.pryrc
.rvmrc
.simplecov
History.rdoc
LICENSE.rdoc
Manifest.txt
README.rdoc
Rakefile
data/thingfish-metastore-pggraph/migrations/20151102_initial.rb
lib/thingfish/metastore/pggraph.rb
lib/thingfish/metastore/pggraph/edge.rb
lib/thingfish/metastore/pggraph/node.rb
spec/spec_helper.rb
spec/thingfish/metastore/pggraph_spec.rb
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.document	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,4 @@
+lib/**/*.rb
+README.rdoc
+ChangeLog.rdoc
+LICENSE.txt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.gems	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,6 @@
+hoe-deveiate -v0.3.0
+pg -v0.18.1
+pluggability -v0.4.0
+sequel -v4.18.0
+sequel_pg -v1.6.11
+strelka -v0.9.0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.gitignore	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,2 @@
+Gemfile.devel*
+html/
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,4 @@
+coverage/
+ChangeLog
+pkg/
+config.yml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.pryrc	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,27 @@
+#!/usr/bin/ruby -*- ruby -*-
+
+require 'configurability'
+require 'loggability'
+require 'pathname'
+require 'strelka'
+
+$LOAD_PATH.unshift( '../Thingfish/lib', 'lib' )
+
+begin
+	require 'thingfish'
+	require 'thingfish/metastore/pggraph'
+
+	Loggability.level = :debug
+	Loggability.format_with( :color )
+
+	if File.exist?( 'config.yml' )
+		Strelka.load_config( 'config.yml' )
+		metastore = Thingfish::Metastore.create( 'pggraph' )
+	end
+
+rescue Exception => e
+	$stderr.puts "Ack! Libraries failed to load: #{e.message}\n\t" +
+		e.backtrace.join( "\n\t" )
+end
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.rvmrc	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+
+# This is an RVM Project .rvmrc file, used to automatically load the ruby
+# development environment upon cd'ing into the directory
+
+# First we specify our desired <ruby>[@<gemset>], the @gemset name is optional,
+# Only full ruby name is supported here, for short names use:
+#     echo "rvm use 2.2.1" > .rvmrc
+environment_id="ruby-2.2@thingfish-metastore-pggraph"
+
+# Uncomment the following lines if you want to verify rvm version per project
+# rvmrc_rvm_version="1.26.11 (master)" # 1.10.1 seems like a safe start
+# eval "$(echo ${rvm_version}.${rvmrc_rvm_version} | __rvm_awk -F. '{print "[[ "$1*65536+$2*256+$3" -ge "$4*65536+$5*256+$6" ]]"}' )" || {
+#   echo "This .rvmrc file requires at least RVM ${rvmrc_rvm_version}, aborting loading."
+#   return 1
+# }
+
+# First we attempt to load the desired environment directly from the environment
+# file. This is very fast and efficient compared to running through the entire
+# CLI and selector. If you want feedback on which environment was used then
+# insert the word 'use' after --create as this triggers verbose mode.
+if [[ -d "${rvm_path:-$HOME/.rvm}/environments"
+  && -s "${rvm_path:-$HOME/.rvm}/environments/$environment_id" ]]
+then
+  \. "${rvm_path:-$HOME/.rvm}/environments/$environment_id"
+  for __hook in "${rvm_path:-$HOME/.rvm}/hooks/after_use"*
+  do
+    if [[ -f "${__hook}" && -x "${__hook}" && -s "${__hook}" ]]
+    then \. "${__hook}" || true
+    fi
+  done
+  unset __hook
+  if (( ${rvm_use_flag:=1} >= 2 )) # display only when forced
+  then
+    if [[ $- == *i* ]] # check for interactive shells
+    then printf "%b" "Using: $(tput setaf 2 2>/dev/null)$GEM_HOME$(tput sgr0 2>/dev/null)\n" # show the user the ruby and gemset they are using in green
+    else printf "%b" "Using: $GEM_HOME\n" # don't use colors in non-interactive shells
+    fi
+  fi
+else
+  # If the environment file has not yet been created, use the RVM CLI to select.
+  rvm --create  "$environment_id" || {
+    echo "Failed to create RVM environment '${environment_id}'."
+    return 1
+  }
+fi
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.simplecov	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,9 @@
+# Simplecov config
+
+SimpleCov.start do
+	add_filter 'spec'
+	add_filter 'integration'
+	add_group "Needing tests" do |file|
+		file.covered_percent < 90
+	end
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/History.rdoc	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,4 @@
+== v0.1.0 [2015-11-05] Mahlon E. Smith <mahlon@martini.nu>
+
+Initial release.
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/LICENSE.rdoc	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,29 @@
+Copyright (c) 2007-2014, Michael Granger and Mahlon E. Smith.
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are
+permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice, this
+  list of conditions and the following disclaimer in the documentation and/or
+  other materials provided with the distribution.
+
+* Neither the name of the authors, nor the names of its contributors may be used to
+  endorse or promote products derived from this software without specific prior
+  written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Manifest.txt	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,16 @@
+.document
+.gems
+.simplecov
+ChangeLog
+History.rdoc
+LICENSE.rdoc
+Manifest.txt
+README.rdoc
+Rakefile
+config.yml
+data/thingfish-metastore-pggraph/migrations/20151102_initial.rb
+lib/thingfish/metastore/pggraph.rb
+lib/thingfish/metastore/pggraph/edge.rb
+lib/thingfish/metastore/pggraph/node.rb
+spec/spec_helper.rb
+spec/thingfish/metastore/pggraph_spec.rb
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README.rdoc	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,77 @@
+= Thingfish PostgreSQL Metastore
+
+* http://bitbucket.org/mahlon/thingfish-metastore-pggraph
+
+== Description
+
+This is a metadata storage plugin for the Thingfish digital asset
+manager.  It provides persistent storage for uploaded data to a
+PostgreSQL table.
+
+It is heavily based on the regular PG metastore, however it differs by
+storing objects as nodes, and their relations as edges.
+
+
+== Authors
+
+* Michael Granger <ged@FaerieMUD.org>
+* Mahlon E. Smith <mahlon@martini.nu>
+
+
+== Installation
+
+    $ gem install thingfish-metastore-pggraph
+
+
+==  Usage
+
+As with Thingfish itself, this plugin uses
+Configurability[https://rubygems.org/gems/configurability] to modify
+default behaviors.
+
+Here's an example configuration file that enables this plugin.
+
+	---
+	thingfish:
+	  metastore: pggraph
+
+	pggraph_metastore:
+	  uri: postgres://thingfish:password@db.example.com/database
+
+
+When Thingfish starts, it will install the necessary database schema
+automatically.
+
+
+== License
+
+Copyright (c) 2014-2015, Michael Granger and Mahlon E. Smith.
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are
+permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice, this
+  list of conditions and the following disclaimer in the documentation and/or
+  other materials provided with the distribution.
+
+* Neither the name of the authors, nor the names of its contributors may be used to
+  endorse or promote products derived from this software without specific prior
+  written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Rakefile	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,70 @@
+#!/usr/bin/env rake
+
+begin
+	require 'hoe'
+rescue LoadError
+	abort "This Rakefile requires hoe (gem install hoe)"
+end
+
+GEMSPEC = 'thingfish-metastore-pggraph.gemspec'
+
+
+Hoe.plugin :mercurial
+Hoe.plugin :signing
+Hoe.plugin :deveiate
+Hoe.plugin :bundler
+
+Hoe.plugins.delete :rubyforge
+Hoe.plugins.delete :gemcutter
+
+hoespec = Hoe.spec 'thingfish-metastore-pggraph' do |spec|
+	spec.readme_file = 'README.rdoc'
+	spec.history_file = 'History.rdoc'
+	spec.extra_rdoc_files = FileList[ '*.rdoc' ]
+	spec.license 'BSD'
+
+	if File.directory?( '.hg' )
+		spec.spec_extras[:rdoc_options] = ['-f', 'fivefish', '-t', 'Thingfish-Metastore-PgGraph']
+	end
+
+	spec.developer 'Michael Granger', 'ged@FaerieMUD.org'
+	spec.developer 'Mahlon E. Smith', 'mahlon@martini.nu'
+
+	spec.dependency 'loggability', '~> 0.10'
+
+	spec.dependency 'rspec', '~> 3.0', :developer
+
+	spec.require_ruby_version( '>=2.0.0' )
+	spec.hg_sign_tags = true if spec.respond_to?( :hg_sign_tags= )
+end
+
+
+ENV['VERSION'] ||= hoespec.spec.version.to_s
+
+# Run the tests before checking in
+task 'hg:precheckin' => [ :check_history, :check_manifest, :spec ]
+
+# Rebuild the ChangeLog immediately before release
+task :prerelease => 'ChangeLog'
+CLOBBER.include( 'ChangeLog' )
+
+desc "Build a coverage report"
+task :coverage do
+	ENV["COVERAGE"] = 'yes'
+	Rake::Task[:spec].invoke
+end
+
+
+task :gemspec => GEMSPEC
+file GEMSPEC => __FILE__ do |task|
+	spec = $hoespec.spec
+	spec.files.delete( '.gemtest' )
+	spec.signing_key = nil
+	spec.version = "#{spec.version}.pre#{Time.now.strftime("%Y%m%d%H%M%S")}"
+	File.open( task.name, 'w' ) do |fh|
+		fh.write( spec.to_ruby )
+	end
+end
+
+task :default => :gemspec
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/data/thingfish-metastore-pggraph/migrations/20151102_initial.rb	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,45 @@
+# vim: set nosta noet ts=4 sw=4:
+
+### The initial Thingfish::Metastore::PgGraph DDL.
+###
+Sequel.migration do
+	up do
+		create_table( :nodes ) do
+			uuid        :id,           primary_key: true
+			text        :format,        null: false
+			int         :extent,        null: false
+			timestamptz :created,       null: false, default: Sequel.function(:now)
+			inet        :uploadaddress, null: false
+			jsonb       :user_metadata, null: false, default: '{}'
+		end
+
+		create_table( :edges ) do
+			uuid  :id_p, null: false
+			uuid  :id_c, null: false
+			jsonb :prop,  null: false, default: '{}'
+
+			index :id_p
+			index :id_c
+
+			# Remove relationships when a node is deleted.
+			foreign_key [:id_p], :nodes, name: 'edges_p_fkey', key: :id, on_delete: :cascade, on_update: :cascade
+			foreign_key [:id_c], :nodes, name: 'edges_c_fkey', key: :id, on_delete: :cascade, on_update: :cascade
+		end
+
+		# Add functional index from JSON edge props
+		run "CREATE INDEX relation_idx ON edges ( (prop->>'relationship') )"
+
+		# Disallow a node linking to itself -- no self loops.
+		run 'ALTER TABLE edges ADD CONSTRAINT no_self_edge_chk CHECK ( id_p <> id_c )'
+
+		# Allow only a single link between any two nodes, enforcing relations
+		# to be directional parent->child.
+		run 'CREATE UNIQUE INDEX pair_unique_idx ON edges USING btree ((LEAST(id_p, id_c)), (GREATEST(id_p, id_c)))'
+	end
+
+	down do
+		drop_table( :nodes, cascade: true )
+		drop_table( :edges, cascade: true )
+	end
+end
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/thingfish/metastore/pggraph.rb	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,307 @@
+# -*- ruby -*-
+#encoding: utf-8
+
+require 'loggability'
+require 'configurability'
+require 'sequel'
+require 'strelka'
+require 'strelka/mixins'
+
+require 'thingfish'
+require 'thingfish/mixins'
+require 'thingfish/metastore'
+
+# Toplevel namespace
+class Thingfish::Metastore::PgGraph < Thingfish::Metastore
+	extend Loggability,
+	       Configurability,
+	       Strelka::MethodUtilities
+	include Thingfish::Normalization
+
+
+	# Load Sequel extensions/plugins
+	Sequel.extension :migration
+
+
+	# Package version
+	VERSION = '0.0.1'
+
+	# Version control revision
+	REVISION = %q$Revision$
+
+	# The data directory that contains migration files.
+	#
+	DATADIR = if ENV['THINGFISH_METASTORE_PGGRAPH_DATADIR']
+			Pathname.new( ENV['THINGFISH_METASTORE_PGGRAPH_DATADIR'] )
+		elsif Gem.datadir( 'thingfish-metastore-pggraph' )
+			Pathname.new( Gem.datadir('thingfish-metastore-pggraph') )
+		else
+			Pathname.new( __FILE__ ).dirname.parent.parent.parent +
+				'data' + 'thingfish-metastore-pggraph'
+		end
+
+	# The default config values
+	DEFAULT_CONFIG = {
+		uri: 'postgres:/thingfish',
+		slow_query_seconds: 0.01,
+	}
+
+
+	# Loggability API -- use a separate logger
+	log_as :thingfish_metastore_pggraph
+
+	# Configurability API -- load the `pg_metastore`
+	config_key :pggraph_metastore
+
+	##
+	# The URI of the database to use for the metastore
+	singleton_attr_accessor :uri
+
+	##
+	# The Sequel::Database that's used to access the metastore tables
+	singleton_attr_accessor :db
+
+	##
+	# The number of seconds to consider a "slow" query
+	singleton_attr_accessor :slow_query_seconds
+
+
+	### Set up the metastore database and migrate to the latest version.
+	def self::setup_database
+		Sequel.extension :pg_json_ops
+
+		self.db = Sequel.connect( self.uri )
+		self.db.logger = Loggability[ Thingfish::Metastore::PgGraph ]
+		self.db.extension :pg_streaming
+		self.db.stream_all_queries = true
+		self.db.optimize_model_load = true
+		self.db.sql_log_level = :debug
+		self.db.extension( :pg_json )
+		self.db.log_warn_duration = self.slow_query_seconds
+
+		# Ensure the database is current.
+		#
+		unless Sequel::Migrator.is_current?( self.db, self.migrations_dir.to_s )
+			self.log.info "Installing database schema..."
+			Sequel::Migrator.apply( self.db, self.migrations_dir.to_s )
+		end
+	end
+
+
+	### Tear down the configured metastore database.
+	def self::teardown_database
+		self.log.info "Tearing down database schema..."
+		Sequel::Migrator.apply( self.db, self.migrations_dir.to_s, 0 )
+	end
+
+
+	### Return the current database migrations directory as a Pathname
+	def self::migrations_dir
+		return DATADIR + 'migrations'
+	end
+
+
+	### Configurability API -- set up the metastore with the `pg_metastore` section of
+	### the config file.
+	def self::configure( config=nil )
+		config = self.defaults.merge( config || {} )
+
+		self.uri                = config[:uri]
+		self.slow_query_seconds = config[:slow_query_seconds]
+
+		self.setup_database
+	end
+
+
+	### Set up the metastore.
+	def initialize( * ) # :notnew:
+		require 'thingfish/metastore/pggraph/node'
+		require 'thingfish/metastore/pggraph/edge'
+		Thingfish::Metastore::PgGraph::Node.db = self.class.db
+		Thingfish::Metastore::PgGraph::Edge.db = self.class.db
+		@model = Thingfish::Metastore::PgGraph::Node
+	end
+
+
+	######
+	public
+	######
+
+	##
+	# The Sequel model representing the metadata rows.
+	attr_reader :model
+
+
+	#
+	# :section: Thingfish::Metastore API
+	#
+
+	### Return an Array of all stored oids.
+	def oids
+		return self.each_oid.to_a
+	end
+
+
+	### Iterate over each of the store's oids, yielding to the block if one is given
+	### or returning an Enumerator if one is not.
+	def each_oid( &block )
+		return self.model.select_map( :id ).each( &block )
+	end
+
+
+	### Save the +metadata+ Hash for the specified +oid+.
+	def save( oid, metadata )
+		md = self.model.from_hash( metadata )
+		md.id = oid
+		md.save
+	end
+
+
+	### Fetch the data corresponding to the given +oid+ as a Hash-ish object.
+	def fetch( oid, *keys )
+		metadata = self.model[ oid ] or return nil
+
+		if keys.empty?
+			return metadata.to_hash
+		else
+			keys = normalize_keys( keys )
+			values = metadata.to_hash.values_at( *keys )
+			return Hash[ [keys, values].transpose ]
+		end
+	end
+
+
+	### Fetch the value of the metadata associated with the given +key+ for the
+	### specified +oid+.
+	def fetch_value( oid, key )
+		metadata = self.model[ oid ] or return nil
+		return metadata.send( key )
+	end
+
+
+	### Fetch UUIDs related to the given +oid+.
+	def fetch_related_oids( oid )
+		oid = normalize_oid( oid )
+		oid = self.model[ oid ]
+		return [] unless oid
+		return oid.related_nodes.map( &:id_c )
+	end
+
+
+	### Search the metastore for UUIDs which match the specified +criteria+ and
+	### return them as an iterator.
+	def search( options={} )
+		ds = self.model.naked.select( :id )
+		self.log.debug "Starting search with %p" % [ ds ]
+
+		ds = self.omit_related_resources( ds, options )
+		ds = self.apply_search_criteria( ds, options )
+		ds = self.apply_search_order( ds, options )
+		ds = self.apply_search_direction( ds, options )
+		ds = self.apply_search_limit( ds, options )
+
+		return ds.map {|row| row[:id] }
+	end
+
+
+	### Update the metadata for the given +oid+ with the specified +values+ hash.
+	def merge( oid, values )
+		values = normalize_keys( values )
+
+		md = self.model[ oid ] or return nil
+		md.merge!( values )
+		md.save
+	end
+
+
+	### Remove all metadata associated with +oid+ from the Metastore.
+	def remove( oid, *keys )
+		self.model[ id: oid ].destroy
+	end
+
+
+	### Remove all metadata associated with +oid+ except for the specified +keys+.
+	def remove_except( oid, *keys )
+		keys = normalize_keys( keys )
+
+		md = self.model[ oid ] or return nil
+		md.user_metadata.keep_if {|key,_| keys.include?(key) }
+		md.save
+	end
+
+
+	### Returns +true+ if the metastore has metadata associated with the specified +oid+.
+	def include?( oid )
+		return self.model.count( id: oid ).nonzero?
+	end
+
+
+	### Returns the number of objects the store contains.
+	def size
+		return self.model.count
+	end
+
+
+	#########
+	protected
+	#########
+
+	### Omit related resources from the search dataset +ds+ unless the given
+	### +options+ specify otherwise.
+	def omit_related_resources( ds, options )
+		unless options[:include_related]
+			self.log.debug "  omitting entries for related resources"
+			ds = ds.unrelated
+		end
+		return ds
+	end
+
+
+	### Apply the search :criteria from the specified +options+ to the collection
+	### in +ds+ and return the modified dataset.
+	def apply_search_criteria( ds, options )
+		if (( criteria = options[:criteria] ))
+			criteria.each do |field, value|
+				self.log.debug "  applying criteria: %p => %p" % [ field.to_s, value ]
+				ds = ds.where_metadata( field => value )
+			end
+		end
+
+		return ds
+	end
+
+
+	### Apply the search :order from the specified +options+ to the collection in
+	### +ds+ and return the modified dataset.
+	def apply_search_order( ds, options )
+		if options[:order]
+			columns = Array( options[:order] )
+			ds = ds.order( columns.map(&:to_sym) )
+		end
+
+		return ds
+	end
+
+
+	### Apply the search :direction from the specified +options+ to the collection
+	### in +ds+ and return the modified dataset.
+	def apply_search_direction( ds, options )
+		ds = ds.reverse if options[:direction] && options[:direction] == 'desc'
+		return ds
+	end
+
+
+	### Apply the search :limit from the specified +options+ to the collection in
+	### +ds+ and return the modified dataset.
+	def apply_search_limit( ds, options )
+		if (( limit = options[:limit] ))
+			self.log.debug "  limiting to %s results" % [ limit ]
+			offset = options[:offset] || 0
+			ds = ds.limit( limit, offset )
+		end
+
+		return ds
+	end
+
+end # class Thingfish::Metastore::PgGraph
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/thingfish/metastore/pggraph/edge.rb	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,52 @@
+# -*- ruby -*-
+#encoding: utf-8
+
+require 'sequel/model'
+
+require 'thingfish/mixins'
+require 'thingfish/metastore/pggraph' unless defined?( Thingfish::Metastore::PgGraph )
+
+
+### A row representing a relationship between two node objects.
+###
+class Thingfish::Metastore::PgGraph::Edge < Sequel::Model( :edges )
+
+	# Related resource associations
+	many_to_one :node, :key => :id_p
+
+	# Dataset methods
+	#
+	dataset_module do
+		#########
+		protected
+		#########
+
+		### Returns a Sequel expression suitable for use as the key of a query against
+		### the specified property field.
+		###
+		def prop_expr( field )
+			return Sequel.pg_jsonb( :prop ).get_text( field.to_s )
+		end
+	end
+
+
+	### Do some initial attribute setup for new objects.
+	###
+	def initialize( * )
+		super
+		self[ :prop ] ||= Sequel.pg_jsonb({})
+	end
+
+
+	#########
+	protected
+	#########
+
+	### Proxy method -- fetch a value from the edge property hash if it exists.
+	###
+	def method_missing( sym, *args, &block )
+		return self.prop[ sym.to_s ] || super
+	end
+
+end # Thingfish::Metastore::PgGraph::Edge
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/thingfish/metastore/pggraph/node.rb	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,182 @@
+# -*- ruby -*-
+#encoding: utf-8
+
+require 'sequel/model'
+
+require 'thingfish/mixins'
+require 'thingfish/metastore/pggraph' unless defined?( Thingfish::Metastore::PgGraph )
+
+
+# A row of metadata describing an asset in a Thingfish store.
+class Thingfish::Metastore::PgGraph::Node < Sequel::Model( :nodes )
+	include Thingfish::Normalization
+
+	# Related resources for this node
+	one_to_many :related_nodes, :key => :id_p, :class => 'Thingfish::Metastore::PgGraph::Edge'
+
+	# Edge relation if this node is a related resource
+	one_to_one :related_to, :key => :id_c, :class => 'Thingfish::Metastore::PgGraph::Edge'
+
+	# Allow instances to be created with a primary key
+	unrestrict_primary_key
+
+
+	# Dataset methods
+	dataset_module do
+
+		### Dataset method: Limit results to metadata which is for a related resource.
+		###
+		def related
+			return self.join_edges( :rel ).exclude( :rel__id_c => nil )
+		end
+
+
+		### Dataset method: Limit results to metadata which is not for a related resource.
+		###
+		def unrelated
+			return self.join_edges( :notrel ).filter( :notrel__id_c => nil )
+		end
+
+
+		### Dataset method: Limit results to records whose operational or user
+		### metadata matches the values from the specified +hash+.
+		###
+		def where_metadata( hash )
+			ds = self
+			hash.each do |field, value|
+
+				# Direct DB column
+				#
+				if self.model.metadata_columns.include?( field.to_sym )
+					ds = ds.where( field.to_sym => value )
+
+				# User metadata or edge relationship
+				#
+				else
+					if field.to_sym == :relationship
+						ds = self.join_edges.filter( Sequel.pg_jsonb( :edges__prop ).get_text( field.to_s ) => value )
+
+					elsif field.to_sym == :relation
+						ds = self.join_edges.filter( :edges__id_p => value )
+
+					else
+						ds = ds.where( self.user_metadata_expr(field) => value )
+					end
+				end
+			end
+
+			return ds
+		end
+
+
+		#########
+		protected
+		#########
+
+		### Return a dataset linking related nodes to edges.
+		###
+		def join_edges( aka=nil )
+			return self.join_table( :left, :edges, { :id_c => :nodes__id }, { :table_alias => aka } )
+		end
+
+
+		### Returns a Sequel expression suitable for use as the key of a query against
+		### the specified user metadata field.
+		def user_metadata_expr( field )
+			return Sequel.pg_jsonb( :user_metadata ).get_text( field.to_s )
+		end
+
+	end # dataset_module
+
+
+	### Return a new Metadata object from the given +oid+ and one-dimensional +hash+
+	### used by Thingfish.
+	def self::from_hash( hash )
+		metadata = Thingfish::Normalization.normalize_keys( hash )
+
+		md = new
+
+		md.format        = metadata.delete( 'format' )
+		md.extent        = metadata.delete( 'extent' )
+		md.created       = metadata.delete( 'created' )
+		md.uploadaddress = metadata.delete( 'uploadaddress' ).to_s
+
+		md.user_metadata = Sequel.pg_jsonb( metadata )
+
+		return md
+	end
+
+
+	### Return the columns of the table that are used for resource metadata.
+	def self::metadata_columns
+		return self.columns - [self.primary_key, :user_metadata]
+	end
+
+
+	### Do some initial attribute setup for new objects.
+	def initialize( * )
+		super
+		self[ :user_metadata ] ||= Sequel.pg_jsonb({})
+	end
+
+
+	### Return the metadata as a Hash; overridden from Sequel::Model to
+	### merge the user and system pairs together.
+	def to_hash
+		hash = self.values.dup
+
+		hash.delete( :id )
+		hash.merge!( hash.delete(:user_metadata) )
+
+		if related_to = self.related_to
+			hash.merge!( related_to.prop )
+			hash[ :relation ] = related_to.id_p
+		end
+
+		return normalize_keys( hash )
+	end
+
+
+	### Merge new metadata +values+ into the metadata for the resource
+	def merge!( values )
+
+		# Extract and set the column-metadata values first
+		self.class.metadata_columns.each do |col|
+			next unless values.key?( col.to_s )
+			self[ col ] = values.delete( col.to_s )
+		end
+
+		self.user_metadata.merge!( values )
+	end
+
+
+	### Hook creation for new related resources, divert relation data to
+	### a new edge row.
+	###
+	def around_save
+		relationship = self.user_metadata.delete( 'relationship' )
+		relation     = self.user_metadata.delete( 'relation' )
+
+		super
+
+		if relation
+			edge = Thingfish::Metastore::PgGraph::Edge.new
+			edge.prop[ 'relationship' ] = relationship
+			edge.id_p = relation
+			edge.id_c = self.id
+			edge.save
+		end
+	end
+
+
+	#########
+	protected
+	#########
+
+	### Proxy method -- fetch a value from the metadata hash if it exists.
+	def method_missing( sym, *args, &block )
+		return self.user_metadata[ sym.to_s ] || super
+	end
+
+end # Thingfish::Metastore::PgGraph::Node
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/spec_helper.rb	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,110 @@
+#!/usr/bin/ruby
+# coding: utf-8
+
+BEGIN {
+	require 'pathname'
+	basedir = Pathname( __FILE__ ).dirname.parent
+
+	thingfishdir = basedir.parent + 'Thingfish'
+	thingfishlib = thingfishdir + 'lib'
+
+	$LOAD_PATH.unshift( thingfishlib.to_s ) if thingfishlib.exist?
+}
+
+
+# SimpleCov test coverage reporting; enable this using the :coverage rake task
+require 'simplecov' if ENV['COVERAGE']
+
+require 'loggability'
+require 'loggability/spechelpers'
+require 'configurability'
+require 'configurability/behavior'
+
+require 'rspec'
+require 'thingfish'
+require 'thingfish/spechelpers'
+require 'thingfish/behaviors'
+require 'thingfish/metastore'
+
+Loggability.format_with( :color ) if $stdout.tty?
+
+# Some helper functions for testing. Usage:
+#
+#    # in spec/spec_helper.rb
+#    RSpec.configure do |c|
+#      c.include( Thingfish::Metastore::PgGraph::SpecHelpers )
+#    end
+#
+#    # in my_class_spec.rb; mark an example as needing database setup
+#    describe MyClass, db: true do
+#    end
+#
+module Thingfish::MetastorePGSpecHelpers
+
+	TESTDB_ENV_VAR = 'THINGFISH_DB_URI'
+
+	### Inclusion callback -- install some hooks
+	def self::included( context )
+
+		context.before( :all ) do
+			if ((db_uri = ENV[ TESTDB_ENV_VAR ]))
+				Thingfish::Metastore::PgGraph.configure( uri: db_uri )
+			end
+		end
+
+		context.after( :all ) do
+			Thingfish::Metastore::PgGraph.teardown_database if Thingfish::Metastore::PgGraph.db
+		end
+
+		context.around( :each ) do |example|
+			if (( setting = example.metadata[:db] ))
+				Loggability[ Thingfish::Metastore::PgGraph ].debug "DB setting: %p" % [ setting ]
+
+				if ((db = Thingfish::Metastore::PgGraph.db))
+					if setting == :no_transaction || setting == :without_transaction
+						Loggability[ Thingfish::Metastore::PgGraph ].debug "  running without a transaction"
+						example.run
+					else
+						Loggability[ Thingfish::Metastore::PgGraph ].debug "  running with a transaction"
+						db.transaction( rollback: :always ) do
+							example.run
+						end
+					end
+				elsif setting.to_s == 'pending'
+					example.metadata[:pending] ||=
+						"a configured database URI in #{TESTDB_ENV_VAR}"
+				else
+					fail "No database connection! " +
+					     "Ensure you have the #{TESTDB_ENV_VAR} ENV variable set to " +
+						 "the URI of an (empty) test database you have write permissions to."
+				end
+			else
+				example.run
+			end
+		end
+
+		super
+	end
+
+end # module Thingfish::Metastore::PgGraph::SpecHelpers
+
+
+### Mock with RSpec
+RSpec.configure do |c|
+	include Thingfish::SpecHelpers
+	include Thingfish::SpecHelpers::Constants
+
+	c.run_all_when_everything_filtered = true
+	c.filter_run :focus
+	# c.order = 'random'
+	c.mock_with( :rspec ) do |mock|
+		mock.syntax = :expect
+	end
+
+	c.include( Loggability::SpecHelpers )
+	c.include( Thingfish::SpecHelpers )
+	c.include( Thingfish::MetastorePGSpecHelpers )
+end
+
+# vim: set nosta noet ts=4 sw=4:
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/thingfish/metastore/pggraph_spec.rb	Thu Nov 05 10:34:15 2015 -0800
@@ -0,0 +1,16 @@
+#!/usr/bin/env rspec -cfd
+#encoding: utf-8
+
+require_relative '../../spec_helper'
+
+require 'rspec'
+
+require 'thingfish/behaviors'
+require 'thingfish/metastore/pggraph'
+
+describe Thingfish::Metastore::PgGraph, db: true do
+
+	it_should_behave_like "a Thingfish metastore"
+
+end
+