SelectionBuilder.java

package net.zer0bandwidth.android.lib.content.querybuilder;

import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.OperationCanceledException;
import android.support.annotation.RequiresApi;
import android.util.Log;

import net.zer0bandwidth.android.lib.util.CollectionsZ;

import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Vector;

import static net.zer0bandwidth.android.lib.content.ContentUtils.QUERY_ORDER_ASCENDING;

/**
 * Builds an insertion query against a given {@link ContentResolver} and
 * {@link Uri}.
 *
 * <h3>Examples</h3>
 *
 * <pre>
 *     Cursor crs = QueryBuilder.selectFrom( rslv, uri )
 *             .allColumns()
 *             .where( "entity_id=?", sID )
 *             .execute()
 *             ;
 * </pre>
 *
 * <pre>
 *     Cursor crs = QueryBuilder.selectFrom( rslv, uri )
 *             .columns( "entity_id", "name", "start_ts", "stop_ts" )
 *             .where( "start_ts>?", TimeUtils.now() )
 *             .orderBy( "name", ContentUtils.QUERY_ORDER_ASCENDING )
 *             .execute()
 *             ;
 * </pre>
 *
 * @since zer0bandwidth-net/android 0.1.7 (#39)
 * @see net.zer0bandwidth.android.lib.content.ContentUtils#QUERY_ORDER_ASCENDING
 * @see net.zer0bandwidth.android.lib.content.ContentUtils#QUERY_ORDER_DESCENDING
 */
public class SelectionBuilder
extends QueryBuilder<SelectionBuilder,Cursor>
{
	protected static final String LOG_TAG =
			SelectionBuilder.class.getSimpleName() ;

	/** The columns to be included in the result set. */
	protected Vector<String> m_vColumns = null ;

	/**
	 * The mapping of sortable columns to sorting directions, if any.
	 * This is a {@code LinkedHashMap} because we want to preserve the order in
	 * which sort keys were added to the order spec. (#52)
	 */
	protected LinkedHashMap<String,String> m_mapSortSpec = null ;

	public SelectionBuilder( ContentResolver rslv, Uri uri )
	{
		super( rslv, uri ) ;
		this.initColumns().initSortSpec() ;
	}

	public SelectionBuilder( Context ctx, Uri uri )
	{
		super( ctx, uri ) ;
		this.initColumns().initSortSpec() ;
	}

	public SelectionBuilder()
	{
		super() ;
		this.initColumns().initSortSpec() ;
	}

	/**
	 * Initializes the vector of columns to be included in the result set.
	 * @return (fluid)
	 */
	protected SelectionBuilder initColumns()
	{
		if( m_vColumns == null )
			m_vColumns = new Vector<>() ;
		else
			m_vColumns.clear() ;
		return this ;
	}

	/**
	 * Initializes the map of sortable columns to sorting directions.
	 * @return (fluid)
	 */
	protected SelectionBuilder initSortSpec()
	{
		if( m_mapSortSpec == null )
			m_mapSortSpec = new LinkedHashMap<>() ;
		else
			m_mapSortSpec.clear() ;
		return this ;
	}

	/**
	 * Specifies that all columns should be included in the result set.
	 * This is the default behavior if left unspecified.
	 * @return (fluid)
	 */
	public SelectionBuilder allColumns()
	{ return this.initColumns() ; }

	/**
	 * Sets the columns that should be included in the result set.
	 *
	 * If selecting all columns, then do not pass {@code null} to this method;
	 * use {@link #allColumns()} instead.
	 *
	 * @param asColumns the names of the columns to be included
	 * @return (fluid)
	 */
	public SelectionBuilder columns( String... asColumns )
	{
		this.initColumns() ;
		if( asColumns == null ) return this ;
		for( String sColumn : asColumns )
		{
			if( ! m_vColumns.contains( sColumn ) )
				m_vColumns.add( sColumn ) ;
		}
		return this ;
	}

	/**
	 * Sets the columns that should be included in the result set.
	 *
	 * If selecting all columns, then do not pass {@code null} to this method;
	 * use {@link #allColumns()} instead.
	 *
	 * @param asColumns the names of the columns to be included
	 * @return (fluid)
	 */
	public SelectionBuilder columns( Collection<String> asColumns )
	{
		if( asColumns == null || asColumns.isEmpty() )
			return this.allColumns() ;

		return this.columns(
				asColumns.toArray( new String[ asColumns.size() ] ) ) ;
	}

	/**
	 * Generates the list of columns to be included in the result set, as an
	 * array of strings to be passed to {@link ContentResolver#query}.
	 * @return a list of column names, or {@code null} if all columns are to be
	 *  included
	 */
	protected String[] getColumns()
	{
		if( m_vColumns == null || m_vColumns.isEmpty() )
			return null ;
		else
			return m_vColumns.toArray( new String[ m_vColumns.size() ] ) ;
	}

	/**
	 * Adds a sorting specification to the query.
	 * @param sColumn the column to be added to the sort specification
	 * @param sDirection the direction
	 * @return (fluid)
	 * @see net.zer0bandwidth.android.lib.content.ContentUtils#QUERY_ORDER_ASCENDING
	 * @see net.zer0bandwidth.android.lib.content.ContentUtils#QUERY_ORDER_DESCENDING
	 */
	public SelectionBuilder orderBy( String sColumn, String sDirection )
	{
		m_mapSortSpec.put( sColumn, sDirection ) ;
		return this ;
	}

	/**
	 * Adds a sorting specification to the query. This column will be sorted in
	 * ascending order.
	 * @param sColumn the column to be added to the sort specification
	 * @return (fluid)
	 */
	public SelectionBuilder orderBy( String sColumn )
	{ return this.orderBy( sColumn, QUERY_ORDER_ASCENDING ) ; }

	/**
	 * Generates the selection's sort criteria as a string, to be supplied to
	 * {@link ContentResolver#query}.
	 * @return a sort specification
	 */
	protected String getSortSpecString()
	{
		if( m_mapSortSpec == null || m_mapSortSpec.isEmpty() )
			return null ;
		StringBuilder sb = new StringBuilder() ;
		for( Map.Entry<String,String> spec : m_mapSortSpec.entrySet() )
		{
			if( sb.length() > 0 ) sb.append( ", " ) ;
			sb.append( spec.getKey() ).append( " " ).append( spec.getValue() ) ;
		}
		return sb.toString() ;
	}

	/**
	 * Selects results from the data context.
	 * @return a set of results from the data context
	 */
	@Override
	public Cursor executeQuery( ContentResolver rslv, Uri uri )
	{
		return rslv.query( uri,
				this.getColumns(),
				this.getWhereFormat(),
				this.getWhereParams(),
				this.getSortSpecString()
			);
	}

	/**
	 * <b><i>(API 16+)</i></b> Selects results from the data context, while
	 * allowing the query to be cancelled in response to the specified signal.
	 * @param sig the signal which would cancel the query
	 * @return a set of results from the data context
	 */
	@RequiresApi(16)
	public Cursor executeOrCancel( CancellationSignal sig )
	throws UnboundException, ExecutionException
	{ return this.executeOrCancel( m_rslv, m_uri, sig ) ; }

	/**
	 * <b><i>(API 16+)</i></b> Selects results from the data context, while
	 * allowing the query to be cancelled in response to the specified signal.
	 * Usually, this is not invoked directly, but is instead consumed by
	 * {@link #executeOrCancel(CancellationSignal)}.
	 * @param rslv the resolver through which the query should be executed
	 * @param uri the URI at which the query should be executed
	 * @param sig the signal which would cancel the query
	 * @return a set of results from the data context
	 * @throws UnboundException if the data context binding is inadequate
	 * @throws ExecutionException if the underlying query fails
	 */
	@RequiresApi(16)
	public Cursor executeOrCancel( ContentResolver rslv, Uri uri, CancellationSignal sig )
	throws UnboundException, ExecutionException
	{
		validateDataContextBinding( rslv, uri ) ;
		if( sig.isCanceled() )
		{
			Log.i( LOG_TAG, "Query already cancelled; returning trivially." ) ;
			return null ;
		}
		try
		{
			return rslv.query( uri,
					this.getColumns(),
					this.getWhereFormat(),
					this.getWhereParams(),
					this.getSortSpecString(),
					sig
				);
		}
		catch( OperationCanceledException xCancel )
		{
			Log.i( LOG_TAG, "Query cancelled while in progress." ) ;
			return null ;
		}
		catch( Exception x )
		{ throw new ExecutionException( LOG_TAG, x ) ; }
	}

	/**
	 * Creates a bundle that describes the query itself, <i>not</i> the result
	 * set that it would select. Used to pass the query specification across an
	 * intent broadcast, in a provider/resolver model. The schema for this
	 * bundle is consistent and is defined as follows:
	 *
	 * <dl>
	 *     <dt>{@link String} {@code uri}</dt>
	 *     <dd>The URI at which the query is aimed.</dd>
	 *     <dt>{@link String}[] {@code columns}</dt>
	 *     <dd>The list of selection columns. Null implies all columns.</dd>
	 *     <dt>{@link String} {@code where_format}</dt>
	 *     <dd>The format string for the query's {@code WHERE} clause.</dd>
	 *     <dt>{@link String}[] {@code where_params}</dt>
	 *     <dd>
	 *         The list of parameters to be substituted in the query's
	 *         {@code WHERE} clause format string.
	 *     </dd>
	 *     <dt>{@link String}[] {@code order_by_cols} <i>(optional)</i></dt>
	 *     <dd>The list of columns on which the query is to be sorted.</dd>
	 *     <dt>{@link String}[] {@code order_by_dirs} <i>(optional)</i></dt>
	 *     <dd>
	 *         The list of sort directions (ascending/descending) for each
	 *         column mentioned in {@code order_by_cols}.
	 *     </dd>
	 * </dl>
	 *
	 * @return a bundle describing the selection query itself
	 * @since zer0bandwidth-net/android 0.1.7 (#50)
	 */
	public Bundle toBundle()
	{
		Bundle bndl = new Bundle() ;
		bndl.putString( "uri", ( this.m_uri != null ?
				this.m_uri.toString() : null ) ) ;
		bndl.putStringArray( "columns", this.getColumns() ) ;
		bndl.putString( "where_format", this.getWhereFormat() ) ;
		bndl.putStringArray( "where_params", this.getWhereParams() ) ;
		if( ! this.m_mapSortSpec.isEmpty() )
		{
			ArrayList<String> asOrderByCols = new ArrayList<>() ;
			ArrayList<String> asOrderByDirs = new ArrayList<>() ;
			for( Map.Entry<String,String> spec : this.m_mapSortSpec.entrySet() )
			{
				asOrderByCols.add( spec.getKey() ) ;
				asOrderByDirs.add( spec.getValue() ) ;
			}
			bndl.putStringArray( "order_by_cols",
					CollectionsZ.of(String.class).toArray(asOrderByCols) ) ;
			bndl.putStringArray( "order_by_dirs",
					CollectionsZ.of(String.class).toArray(asOrderByDirs) ) ;
		}
		return bndl ;
	}
}