SQLiteHouseKeeper.java

package net.zer0bandwidth.android.lib.database.sqlitehouse.content;

import android.content.BroadcastReceiver;
import android.content.ContentProvider;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.util.Log;

import net.zer0bandwidth.android.lib.content.ContentUtils;
import net.zer0bandwidth.android.lib.content.IntentUtils;
import net.zer0bandwidth.android.lib.database.querybuilder.SelectionBuilder;
import net.zer0bandwidth.android.lib.database.sqlitehouse.SQLightable;
import net.zer0bandwidth.android.lib.database.sqlitehouse.SQLiteHouse;
import net.zer0bandwidth.android.lib.database.sqlitehouse.content.exceptions.SQLiteContentException;
import net.zer0bandwidth.android.lib.database.sqlitehouse.exceptions.IntrospectionException;
import net.zer0bandwidth.android.lib.database.sqlitehouse.exceptions.SchematicException;
import net.zer0bandwidth.android.lib.util.CollectionsZ;

import java.util.ArrayList;
import java.util.List;

import static net.zer0bandwidth.android.lib.database.SQLiteSyntax.DELETE_FAILED;
import static net.zer0bandwidth.android.lib.database.SQLiteSyntax.INSERT_FAILED;
import static net.zer0bandwidth.android.lib.database.SQLiteSyntax.UPDATE_FAILED;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.EXTRA_INSERT_ROW_ID;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.EXTRA_RESULT_ROW_COUNT;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.EXTRA_SCHEMA_CLASS_DATA;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.EXTRA_SCHEMA_CLASS_NAME;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.EXTRA_SELECTION_QUERY_SPEC;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.KEEPER_DELETE;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.KEEPER_INSERT;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.KEEPER_SELECT;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.KEEPER_UPDATE;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.RELAY_NOTIFY_DELETE;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.RELAY_NOTIFY_DELETE_FAILED;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.RELAY_NOTIFY_INSERT;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.RELAY_NOTIFY_INSERT_FAILED;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.RELAY_NOTIFY_SELECT_FAILED;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.RELAY_NOTIFY_UPDATE;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.RELAY_NOTIFY_UPDATE_FAILED;
import static net.zer0bandwidth.android.lib.database.sqlitehouse.content.SQLiteHouseSignalAPI.RELAY_RECEIVE_SELECTION;

/**
 * A class which is bound to a {@link SQLiteHouse} implementation and receives
 * intents that request queries from the underlying database.
 *
 * This class fills the role of a {@link ContentProvider} without implementing
 * that class's API, since the prototypes of the {@code ContentProvider}'s
 * methods don't fit with the workflow of a {@code SQLiteHouse}.
 *
 * @param <H> the {@link SQLiteHouse} implementation to which this provider is
 *           bound
 * @since zer0bandwidth-net/android 0.1.7 (#50)
 */
public class SQLiteHouseKeeper<H extends SQLiteHouse>
extends BroadcastReceiver
{
/// Static constants ///////////////////////////////////////////////////////////

	public static final String LOG_TAG =
			SQLiteHouseKeeper.class.getSimpleName() ;

/// Static methods /////////////////////////////////////////////////////////////

/// Inner instance classes /////////////////////////////////////////////////////

	/**
	 * A default implementation of {@link SQLiteHouseSignalAPI} for this keeper
	 * class. The "domain" string is defaulted to the canonical name of the
	 * {@link SQLiteHouse} implementation to which the keeper is bound.
	 * @since zer0bandwidth-net/android 0.1.7 (#50)
	 */
	public class DefaultSignals
	extends SQLiteHouseSignalAPI
	{
		protected String getIntentDomain()
		{ return SQLiteHouseKeeper.this.m_house.getClass().getCanonicalName(); }
	}

/// Member fields //////////////////////////////////////////////////////////////

	/** The context in which the keeper will operate. */
	protected Context m_ctx = null ;

	/** A persistent handle on the {@link SQLiteHouse} implementation class. */
	protected Class<H> m_cls ;

	/** A persistent reference to the database helper instance. */
	protected H m_house = null ;

	/** A reference for the contract under which the keeper was registered. */
	protected SQLiteHouseSignalAPI m_api = null ;

/// Constructors and initializers //////////////////////////////////////////////

	/**
	 * Constructs an instance, but does not register it.
	 * @param ctx the context in which the keeper will operate
	 * @param cls the {@link SQLiteHouse} implementation class
	 * @param dbh the {@link SQLiteHouse} implementation instance to which the
	 *            keeper is bound
	 */
	public SQLiteHouseKeeper( Context ctx, Class<H> cls, H dbh )
	{
		m_ctx = ctx ;
		m_cls = cls ;
		m_house = dbh ;
		m_api = null ;
	}

/// Receiver registration //////////////////////////////////////////////////////

	/**
	 * Registers the keeper instance as a {@link BroadcastReceiver} in its
	 * context, using the canonical name of the underlying {@link SQLiteHouse}
	 * implementation class as the contractual "authority".
	 * @return (fluid)
	 * @see DefaultSignals
	 * @see SQLiteHouseSignalAPI
	 */
	public SQLiteHouseKeeper<H> register()
	{ return this.register( new DefaultSignals() ) ; }

	/**
	 * Registers the keeper instance as a {@link BroadcastReceiver} in its
	 * context.
	 * @param api the signal contract between the keeper and its relays; if
	 *             {@code null}, then the keeper will be unregistered instead!
	 * @return (fluid)
	 */
	public SQLiteHouseKeeper<H> register( SQLiteHouseSignalAPI api )
	{
		m_api = api ;
		if( api == null )
			this.unregister() ;
		else
			m_ctx.registerReceiver( this, api.getKeeperIntentFilter() ) ;
		return this ;
	}

	/**
	 * Unregisters the keeper in its context.
	 * @return (fluid)
	 */
	public SQLiteHouseKeeper<H> unregister()
	{
		ContentUtils.unregister( m_ctx, this ) ;
		m_api = null ;
		return this ;
	}

/// android.content.BroadcastReceiver //////////////////////////////////////////

	@Override
	public final void onReceive( Context ctx, Intent sig )
	{
		String sAction = IntentUtils.discoverAction(sig) ;

		if( sAction == null || sAction.isEmpty() )
		{
			Log.i( LOG_TAG, "Ignoring request with empty action token." ) ;
			return ;
		}

		if( m_api == null )
		{
			Log.i( LOG_TAG, (new StringBuilder())
					.append( "No signals are registered! Ignoring action [" )
					.append( sAction )
					.append( "]." )
					.toString()
				);
			return ;
		}

		String sActionToken = m_api.getTokenFromKeeperAction(sAction) ;

		switch( sActionToken )
		{
			case KEEPER_INSERT:
				this.insert(sig) ;
				break ;
			case KEEPER_SELECT:
				this.select(sig) ;
				break ;
			case KEEPER_UPDATE:
				this.update(sig) ;
				break ;
			case KEEPER_DELETE:
				this.delete(sig) ;
				break ;
			default:
				this.handleCustomAction( ctx, sig, sActionToken ) ;
		}
	}

/// Action handlers ////////////////////////////////////////////////////////////

	/**
	 * Override this method to handle custom actions not covered by the standard
	 * set defined in {@link SQLiteHouseSignalAPI}. The default implementation
	 * writes an information log stating that the action is unrecognized.
	 * @param ctx the context from which the signal originated
	 * @param sig the received signal
	 * @param sToken the action token parsed from the signal
	 */
	@SuppressWarnings("UnusedParameters") // default intentionally ignores
	protected void handleCustomAction( Context ctx, Intent sig, String sToken )
	{
		Log.i( LOG_TAG, (new StringBuilder())
				.append( "Ignoring unrecognized action [" )
				.append( sToken )
				.append( "]." )
				.toString()
			);
	}

	/**
	 * Handles a request to insert data into the underlying database.
	 * @param sig the received signal
	 * @param <SC> the schematic class that is discovered along the way
	 * @return the inserted row ID
	 */
	protected synchronized <SC extends SQLightable> long insert( Intent sig )
	{
		long nRowID ;
		Class<SC> cls = null ;
		SC o = null ;
		try
		{
			cls = m_api.getClassFromExtra(sig) ;
			o = m_api.getDataFromBundle( sig, cls ) ;
			nRowID = m_house.insert(o) ;
		}
		catch( SQLiteContentException xContent )
		{
			Log.e( LOG_TAG, "Malformed intent received by insert().",
					xContent ) ;
			nRowID = INSERT_FAILED ;
		}
		catch( IntrospectionException | SchematicException xSchema )
		{
			Log.e( LOG_TAG, "Failed to insert an object.", xSchema ) ;
			nRowID = INSERT_FAILED ;
		}

		if( nRowID != INSERT_FAILED )
			this.notifyInserted( nRowID, cls, o ) ;
		else if( cls != null )
			this.notifyInsertFailed( cls.getCanonicalName() ) ;
		else
			this.notifyInsertFailed( null ) ;

		return nRowID ;
	}

	/**
	 * Handles a request to update a record in the underlying database.
	 * @param sig the received signal
	 * @param <SC> the schematic class of the row to be updated
	 * @return the number of updated rows (probably 0, 1, or -1)
	 */
	protected synchronized <SC extends SQLightable> int update( Intent sig )
	{
		int nRowsUpdated ;
		Class<SC> cls = null ;
		try
		{
			cls = m_api.getClassFromExtra(sig) ;
			SC o = m_api.getDataFromBundle( sig, cls ) ;
			nRowsUpdated = m_house.update(o) ;
		}
		catch( SQLiteContentException xContent )
		{
			Log.e( LOG_TAG, "Malformed intent received by update().",
					xContent ) ;
			nRowsUpdated = UPDATE_FAILED ;
		}
		catch( IntrospectionException | SchematicException xSchema )
		{
			Log.e( LOG_TAG, "Failed to update an object.", xSchema ) ;
			nRowsUpdated = UPDATE_FAILED ;
		}

		if( nRowsUpdated != UPDATE_FAILED )
			this.notifyUpdated( cls, nRowsUpdated ) ;
		else if( cls != null )
			this.notifyUpdateFailed( cls.getCanonicalName() ) ;
		else
			this.notifyUpdateFailed( null ) ;

		return nRowsUpdated ;
	}

	/**
	 * Handles a request to delete a record in the underlying database.
	 * @param sig the received signal
	 * @param <SC> the schematic class of the row to be deleted
	 * @return the number of deleted rows (probably 0, 1, or -1)
	 */
	protected synchronized <SC extends SQLightable> int delete( Intent sig )
	{
		int nRowsDeleted ;
		Class<SC> cls = null ;
		try
		{
			cls = m_api.getClassFromExtra(sig) ;
			SC o = m_api.getDataFromBundle( sig, cls ) ;
			nRowsDeleted = m_house.delete(o) ;
		}
		catch( SQLiteContentException xContent )
		{
			Log.e( LOG_TAG, "Malformed intent received by delete().",
					xContent ) ;
			nRowsDeleted = DELETE_FAILED ;
		}
		catch( IntrospectionException | SchematicException xSchema )
		{
			Log.e( LOG_TAG, "Failed to delete an object.", xSchema ) ;
			nRowsDeleted = DELETE_FAILED ;
		}

		if( nRowsDeleted != DELETE_FAILED )
			this.notifyDeleted( cls, nRowsDeleted ) ;
		else if( cls != null )
			this.notifyDeleteFailed( cls.getCanonicalName() ) ;
		else
			this.notifyDeleteFailed( null ) ;

		return nRowsDeleted ;
	}

	/**
	 * Handles a request to select records from the underlying database.
	 * @param sig the received signal
	 * @param <SC> the schematic class of the rows to be selected
	 */
	protected synchronized <SC extends SQLightable> void select( Intent sig )
	{
		Class<SC> cls = null ;
		ArrayList<SC> aoResults = null ;
		try
		{
			cls = m_api.getClassFromExtra(sig) ;
			Bundle bndlSpec = sig.getBundleExtra( EXTRA_SELECTION_QUERY_SPEC ) ;
			SelectionBuilder q = this.parseSelectionSpec( cls, bndlSpec ) ;
			Cursor crs = q.execute() ;
			if( crs.moveToFirst() )
			{
				aoResults = new ArrayList<>( crs.getCount() ) ;
				SQLightable.Reflection<SC> tbl = m_api.reflect(cls) ;
				while( ! crs.isAfterLast() )
				{
					aoResults.add( tbl.fromCursor(crs) ) ;
					crs.moveToNext() ;
				}
			}
		}
		catch( IntrospectionException | SchematicException xSchema )
		{
			Log.e( LOG_TAG, "Failed to set up selection operation.", xSchema ) ;
			this.notifySelectionFailed( null ) ;
		}
		catch( Exception x )
		{
			Log.e( LOG_TAG, "Selection failed for unknown cause.", x ) ;
			this.notifySelectionFailed(( cls != null ?
					cls.getCanonicalName() : null )) ;
		}
		this.sendSelectionResults( cls, aoResults ) ;
	}

	/**
	 * Prepares a {@link SelectionBuilder} with query specifications received in
	 * a signal from the relay.
	 * @param cls the schematic class that would contain the rows
	 * @param bndl a selection query specification
	 * @param <SC> the schematic class
	 * @return a selection builder bound to the underlying database
	 * @see net.zer0bandwidth.android.lib.content.querybuilder.SelectionBuilder#toBundle()
	 */
	protected synchronized <SC extends SQLightable> SelectionBuilder
	parseSelectionSpec( Class<SC> cls, Bundle bndl )
	{
		SelectionBuilder q = m_house.selectFrom(cls) ;
		if( bndl.containsKey("columns") )
			q.columns( bndl.getStringArray("columns") ) ;
		if( bndl.containsKey("where_format") )
		{
			if( bndl.containsKey("where_params") )
			{
				q.where( bndl.getString("where_format"),
						bndl.getStringArray("where_params") ) ;
			}
			else
				q.where( bndl.getString("where_format") ) ;
		}
		if( bndl.containsKey("order_by_cols") )
		{
			String[] asOrderByCols = bndl.getStringArray("order_by_cols") ;
			String[] asOrderByDirs = bndl.getStringArray("order_by_dirs") ;
			if( asOrderByCols != null && asOrderByDirs != null )
			{
				for( int i = 0 ; i < asOrderByCols.length ; i++ )
					q.orderBy( asOrderByCols[i], asOrderByDirs[i] ) ;
			}
		}
		return q ;
	}

/// Broadcasts to SQLiteHouseRelay /////////////////////////////////////////////

	/**
	 * Notifies the relay that an insertion succeeded.
	 * @param cls the schematic class of the inserted data
	 * @param nRowID the ID of the inserted row
	 * @param <SC> the schematic class of the inserted data
	 */
	protected synchronized <SC extends SQLightable> void notifyInserted(
			long nRowID, Class<SC> cls, SC row )
	{
		Intent sig = new Intent( m_api.getFormattedRelayAction(
				RELAY_NOTIFY_INSERT ) ) ;
		sig.putExtra( m_api.getFormattedExtraTag( EXTRA_INSERT_ROW_ID ),
				nRowID ) ;
		sig.putExtra( m_api.getFormattedExtraTag( EXTRA_SCHEMA_CLASS_NAME ),
				cls.getCanonicalName() ) ;
		sig.putExtra( m_api.getFormattedExtraTag( EXTRA_SCHEMA_CLASS_DATA ),
				m_api.reflect(cls).toBundle(row) ) ;
		m_ctx.sendBroadcast( sig ) ;
	}

	/**
	 * Notifies the relay that an insertion failed.
	 * @param sClass the name of the class that would have been inserted
	 */
	protected synchronized void notifyInsertFailed( String sClass )
	{
		Intent sig = new Intent( m_api.getFormattedRelayAction(
				RELAY_NOTIFY_INSERT_FAILED ) ) ;
		if( sClass != null )
		{
			sig.putExtra( m_api.getFormattedExtraTag( EXTRA_SCHEMA_CLASS_NAME ),
					sClass ) ;
		}
		m_ctx.sendBroadcast( sig ) ;
	}

	/**
	 * Notifies the relay that an update operation succeeded.
	 * @param cls the schematic class of the updated data
	 * @param nRows the number of rows that were updated
	 * @param <SC> the schematic class of the updated data
	 */
	protected synchronized <SC extends SQLightable> void notifyUpdated(
			Class<SC> cls, int nRows )
	{
		Intent sig = new Intent( m_api.getFormattedRelayAction(
				RELAY_NOTIFY_UPDATE ) ) ;
		sig.putExtra( m_api.getFormattedExtraTag( EXTRA_SCHEMA_CLASS_NAME ),
				cls.getCanonicalName() ) ;
		sig.putExtra( m_api.getFormattedExtraTag(EXTRA_RESULT_ROW_COUNT),
				nRows ) ;
		m_ctx.sendBroadcast( sig ) ;
	}

	/**
	 * Notifies the relay that an update failed.
	 * @param sClass the name of the class that would have been updated
	 */
	protected synchronized void notifyUpdateFailed( String sClass )
	{
		Intent sig = new Intent( m_api.getFormattedRelayAction(
				RELAY_NOTIFY_UPDATE_FAILED ) ) ;
		if( sClass != null )
		{
			sig.putExtra( m_api.getFormattedExtraTag( EXTRA_SCHEMA_CLASS_NAME ),
					sClass ) ;
		}
		m_ctx.sendBroadcast( sig ) ;
	}

	/**
	 * Notifies the relay that rows have been deleted.
	 * @param cls the schematic class of the deleted data
	 * @param nRows the number of rows that were updated
	 * @param <SC> the schematic class of the updated data
	 */
	protected synchronized <SC extends SQLightable> void notifyDeleted(
			Class<SC> cls, int nRows )
	{
		Intent sig = new Intent( m_api.getFormattedRelayAction(
				RELAY_NOTIFY_DELETE ) ) ;
		sig.putExtra( m_api.getFormattedExtraTag( EXTRA_SCHEMA_CLASS_NAME ),
				cls.getCanonicalName() ) ;
		sig.putExtra( m_api.getFormattedExtraTag(EXTRA_RESULT_ROW_COUNT),
				nRows ) ;
		m_ctx.sendBroadcast( sig ) ;
	}

	/**
	 * Notifies the relay that a deletion failed.
	 * @param sClass the name of the class that would have had rows deleted
	 */
	protected synchronized void notifyDeleteFailed( String sClass )
	{
		Intent sig = new Intent( m_api.getFormattedRelayAction(
				RELAY_NOTIFY_DELETE_FAILED ) ) ;
		if( sClass != null )
		{
			sig.putExtra( m_api.getFormattedExtraTag( EXTRA_SCHEMA_CLASS_NAME ),
					sClass ) ;
		}
		m_ctx.sendBroadcast( sig ) ;
	}

	/**
	 * Broadcasts a result set to the relay.
	 * @param cls the schematic class that would contain the rows
	 * @param aoResults the result set
	 * @param <SC> the schematic class
	 */
	protected synchronized <SC extends SQLightable> void
	sendSelectionResults( Class<SC> cls, List<SC> aoResults )
	{ m_ctx.sendBroadcast( this.buildResultBroadcast( cls, aoResults ) ) ; }

	/**
	 * Builds the intent that would be sent by {@link #sendSelectionResults}.
	 * @param cls the schematic class that would contain the rows
	 * @param aoResults the result set
	 * @param <SC> the schematic class
	 * @return the intent to be sent by {@link #sendSelectionResults}
	 */
	protected synchronized <SC extends SQLightable> Intent
	buildResultBroadcast( Class<SC> cls, List<SC> aoResults )
	{
		Intent sig = new Intent( m_api.getFormattedRelayAction(
				RELAY_RECEIVE_SELECTION ) ) ;
		sig.putExtra( m_api.getFormattedExtraTag( EXTRA_SCHEMA_CLASS_NAME ),
				cls.getCanonicalName() ) ;
		if( aoResults == null )
		{
			sig.putExtra( m_api.getFormattedExtraTag( EXTRA_RESULT_ROW_COUNT ),
					0 ) ;
		}
		else
		{
			SQLightable.Reflection<SC> tbl = m_api.reflect(cls) ;
			sig.putExtra( m_api.getFormattedExtraTag( EXTRA_RESULT_ROW_COUNT ),
					aoResults.size() ) ;
			ArrayList<Bundle> abndlResults =
					new ArrayList<>( aoResults.size() ) ;
			for( SC o : aoResults )
				abndlResults.add( tbl.toBundle(o) ) ;
			sig.putExtra( m_api.getFormattedExtraTag( EXTRA_SCHEMA_CLASS_DATA ),
					CollectionsZ.of(Bundle.class).toArray(abndlResults) ) ;
		}
		return sig ;
	}

	/**
	 * Notifies the relay that a selection failed.
	 * @param sClass the name of the class that would have had rows selected
	 */
	protected synchronized void notifySelectionFailed( String sClass )
	{
		Intent sig = new Intent( m_api.getFormattedRelayAction(
				RELAY_NOTIFY_SELECT_FAILED ) ) ;
		if( sClass != null )
		{
			sig.putExtra(m_api.getFormattedExtraTag(EXTRA_SCHEMA_CLASS_NAME),
					sClass);
		}
		m_ctx.sendBroadcast( sig ) ;
	}

/// Other accessors and mutators ///////////////////////////////////////////////

}