Initial commit.
--- /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
+