# HG changeset patch # User Mahlon E. Smith # Date 1446748455 28800 # Node ID 3cc90e88c6ab412f771b0d213eef569328987566 Initial commit. diff -r 000000000000 -r 3cc90e88c6ab .document --- /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 diff -r 000000000000 -r 3cc90e88c6ab .gems --- /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 diff -r 000000000000 -r 3cc90e88c6ab .gitignore --- /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/ diff -r 000000000000 -r 3cc90e88c6ab .hgignore --- /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 diff -r 000000000000 -r 3cc90e88c6ab .pryrc --- /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 + + diff -r 000000000000 -r 3cc90e88c6ab .rvmrc --- /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 [@], 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 diff -r 000000000000 -r 3cc90e88c6ab .simplecov --- /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 diff -r 000000000000 -r 3cc90e88c6ab History.rdoc --- /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 + +Initial release. + diff -r 000000000000 -r 3cc90e88c6ab LICENSE.rdoc --- /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. diff -r 000000000000 -r 3cc90e88c6ab Manifest.txt --- /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 diff -r 000000000000 -r 3cc90e88c6ab README.rdoc --- /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 +* Mahlon E. Smith + + +== 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. + diff -r 000000000000 -r 3cc90e88c6ab Rakefile --- /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 + diff -r 000000000000 -r 3cc90e88c6ab data/thingfish-metastore-pggraph/migrations/20151102_initial.rb --- /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 + diff -r 000000000000 -r 3cc90e88c6ab lib/thingfish/metastore/pggraph.rb --- /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 + diff -r 000000000000 -r 3cc90e88c6ab lib/thingfish/metastore/pggraph/edge.rb --- /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 + diff -r 000000000000 -r 3cc90e88c6ab lib/thingfish/metastore/pggraph/node.rb --- /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 + diff -r 000000000000 -r 3cc90e88c6ab spec/spec_helper.rb --- /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: + diff -r 000000000000 -r 3cc90e88c6ab spec/thingfish/metastore/pggraph_spec.rb --- /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 +