lib/thingfish/metastore/pggraph.rb
changeset 0 3cc90e88c6ab
child 1 60b437614edd
--- /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
+