/*
* Copyright 2008 Fedora Commons, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.mulgara.webquery;
import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.jrdf.graph.BlankNode;
import org.jrdf.graph.Literal;
import org.jrdf.graph.Node;
import org.jrdf.graph.URIReference;
import org.mulgara.query.Answer;
import org.mulgara.query.TuplesException;
import org.mulgara.query.Variable;
import org.mulgara.query.operation.Command;
import org.mulgara.util.QueryParams;
import org.mulgara.util.functional.Pair;
import org.mulgara.webquery.html.Anchor;
import org.mulgara.webquery.html.HtmlElement;
import org.mulgara.webquery.html.Span;
import org.mulgara.webquery.html.Strong;
import org.mulgara.webquery.html.Table;
import org.mulgara.webquery.html.TableData;
import org.mulgara.webquery.html.TableHeader;
import org.mulgara.webquery.html.TableRow;
import org.mulgara.webquery.html.Text;
import org.mulgara.webquery.html.HtmlElement.Attr;
import org.mulgara.webquery.html.HtmlElement.Entity;
import static org.mulgara.webquery.Template.*;
/**
* Constructs and emits the response page for a set of queries.
*
* @created Aug 4, 2008
* @author Paula Gearon
* @copyright © 2008 <a href="http://www.fedora-commons.org/">Fedora Commons</a>
*/
public class QueryResponsePage {
/** The name of the system property that can override the page size. */
public static final String PAGE_SIZE_PROP = "webui.page.size";
/** The default number of results per page or per cell. */
public static final int DEFAULT_RESULTS_PER_PAGE = 250;
/** The number of results per page or per cell. */
static int resultsPerPage;
/** The request that asked for this page. */
HttpServletRequest request;
/** The structure for sending the page back to the client. */
HttpServletResponse response;
/** The absolute file path of the template header, with the root set to the resource directory. */
final String templateHeadFile;
/** The absolute file path of the template footer, with the root set to the resource directory. */
final String templateTailFile;
/** A map of tags to the values that should replace them. */
Map<String,String> tagMap;
/** The object for accepting the output stream. */
PrintWriter out = null;
/** The set of unfinished results to render, mapped to the remaining sizes. */
Map<Answer,Pair<Long,Command>> unfinishedResults;
static {
try {
resultsPerPage = Integer.parseInt(System.getProperty(PAGE_SIZE_PROP));
} catch (Exception e) {
resultsPerPage = DEFAULT_RESULTS_PER_PAGE;
}
}
/**
* Construct this page for responsing to a particular request environment.
* @param req The request that asked for this page.
* @param resp The structure for sending the page back to the client.
* @param tagMap A map of tags to the values that should replace them
* @param headFile the header
* @param tailFile the footer
*/
@SuppressWarnings("unchecked")
public QueryResponsePage(HttpServletRequest req, HttpServletResponse resp, Map<String,String> tagMap, String headFile, String tailFile) {
this.request = req;
this.response = resp;
this.tagMap = tagMap;
this.templateHeadFile = headFile;
this.templateTailFile = tailFile;
this.unfinishedResults = (Map<Answer,Pair<Long,Command>>)req.getSession().getAttribute(UNFINISHED_RESULTS);
}
/**
* Send a result page to the client without any stats. This starts with a form template, and then
* follows up with a single result.
* @param cmd The command that was run.
* @param result The result returned from running the command.
* @throws IOException There was an error writing to the client, or reading resources.
*/
public void writeResult(Command cmd, Answer result) throws IOException {
List<Command> commands = Collections.singletonList(cmd);
List<Object> results = Collections.singletonList((Object)result);
writeResults(-1L, commands, results);
}
/**
* Send a result page to the client. This starts with a form template, and then
* follows up with the results.
* @param time The time take to execute the commands to get these results.
* @param cmds The commands that were run.
* @param results The result list returned from running each command.
* @throws IOException There was an error writing to the client, or reading resources.
*/
public void writeResults(long time, List<Command> cmds, List<Object> results) throws IOException {
response.setContentType("text/html");
response.setHeader("pragma", "no-cache");
PrintWriter output = getOutput();
// write the head of the page
new ResourceTextFile(templateHeadFile, tagMap).sendTo(output);
// summarise the results first
writeResultSummary(time, results.size());
// write the results
Iterator<Command> c = cmds.iterator();
Iterator<Object> r = results.iterator();
while (c.hasNext()) writeResult(c.next(), r.next());
// Check that we exhausted the results, like we were supposed to
if (r.hasNext()) response.sendError(SC_INTERNAL_SERVER_ERROR, "Internal error: results do not match queries.");
// write the tail of the page
new ResourceTextFile(templateTailFile).sendTo(output);
output.close();
}
/**
* Write a summary for the results of all the executed commands.
* @param time The time taken to execute the commands, in milliseconds.
* @param nrCommands The number of commands executed.
*/
private void writeResultSummary(long time, int nrCommands) throws IOException {
// short circuit if we don't have a summary
if (time < 0) return;
HtmlElement row = new TableRow(ROW_INDENT);
row.add(new Span(CSS_LARGE, "Results:"));
Text t = new Text("(" + nrCommands);
t.append(nrCommands == 1 ? " query, " : " queries, ");
t.append(String.format("%.3f", (time / 1000.0))).append(" seconds)");
row.add(t);
emitTopLevelElement(row);
}
/**
* Write a single result into a row of an existing table.
* @param cmd The command executed to give this result.
* @param result The result for the command cmd.
* @throws IOException Occurs for an error writing the response.
*/
private void writeResult(Command cmd, Object result) throws IOException {
if (result instanceof Answer) writeAnswerResult(cmd, (Answer)result);
else writeSimpleResult(cmd);
}
/**
* Write a simple result into a row of an existing table.
* @param cmd The command executed to give this result.
* @throws IOException Occurs for an error writing the response.
*/
private void writeSimpleResult(Command cmd) throws IOException {
HtmlElement row = new TableRow(ROW_INDENT);
row.add(new Span(CSS_LARGE, "Query Executed:"));
row.add(new TableData(cmd.getText()).addAttr(Attr.VALIGN, "top").addAttr(Attr.WIDTH, "100%"));
emitTopLevelElement(row);
row = new TableRow(ROW_INDENT);
row.add(new Span(CSS_LARGE, "Result Message:"));
row.add(new TableData(cmd.getResultMessage()).addAttr(Attr.VALIGN, "top").addAttr(Attr.WIDTH, "100%"));
emitTopLevelElement(row);
}
/**
* Write a complex result into a row of an existing table.
* @param cmd The command executed to give this result.
* @param result The result for the command cmd.
* @throws IOException Occurs for an error writing the response.
*/
private void writeAnswerResult(Command cmd, Answer result) throws IOException {
// create a row with the query that was executed
TableRow row = new TableRow(ROW_INDENT);
row.add(new Span(CSS_LARGE, "Query Executed:"));
row.add(new TableData(cmd.getText()).addAttr(Attr.VALIGN, "top").addAttr(Attr.WIDTH, "100%"));
emitTopLevelElement(row);
// create the result table, and put it in a cell
TableData resultData = new TableData(createResultTable(result, cmd));
resultData.addAttr(Attr.COLSPAN, 2).addAttr(Attr.CLASS, CSS_RESULT_TABLE_CELL);
// create a new row to hold the result table
row = new TableRow(ROW_INDENT, resultData);
emitTopLevelElement(row);
}
/**
* Convert a result into an HTML table.
* @param result The result to convert.
* @return A new table containing all the requested results.
* @throws IOException If there was an error reading the results
*/
private Table createResultTable(Answer result, Command cmd) throws IOException {
// create the table for the results
Table resultTable = new Table();
resultTable.addAttr(Attr.CLASS, CSS_RESULT_TABLE);
// Get the columns to be displayed
Variable[] vars = result.getVariables();
// add the headers for the table
TableRow headerRow = new TableRow();
for (Variable v: vars) headerRow.add(new TableHeader(new Strong(v.getName())));
resultTable.add(headerRow);
// add the result data
try {
int rowsLeft = resultsPerPage;
// if this is the first time we've seen this result, then go to the start
if (!isResuming(result)) result.beforeFirst();
while (result.next()) {
TableRow row = new TableRow();
for (int c = 0; c < vars.length; c++) row.add(createEmbeddableElement(result.getObject(c), cmd));
resultTable.add(row);
// exit early if this is too big
if (--rowsLeft == 0) {
addUnfinishedResult(result, resultsPerPage, cmd);
appendNextPageLink(result, resultTable);
return resultTable;
}
}
// got to the end of this result, so clean it up
resultFinished(result);
} catch (TuplesException e) {
throw (IOException)
new IOException("Error accessing the results of the query: " + e.getMessage()).initCause(e);
}
return resultTable;
}
/**
* Converts an object returned from a query into an element in a table.
* If the object is a simple type, then it returns text linked to a query
* for that element. For Answer object, it returns a new table.
* @param obj The object to convert into a table element.
* @param cmd The command that gave rise to the object.
* @return A new table data element.
* @throws IOException If the data could not be read.
*/
private TableData createEmbeddableElement(Object obj, Command cmd) throws IOException {
HtmlElement result = null;
if (obj instanceof Answer) result = createResultTable((Answer)obj, cmd);
if (obj instanceof BlankNode) result = new Text(obj.toString());
if (obj instanceof URIReference || obj instanceof Literal) result = getElementQuery((Node)obj);
// only null should make it this far without setting the result
if (result == null) {
if (obj != null) throw new IllegalArgumentException("Unknown type in result data");
result = new TableData(new Text(Entity.NBSP));
}
if (!(result instanceof TableData)) result = new TableData(result);
result.addAttr(Attr.CLASS, "rtd"); // set the style to Result-Table-Data
return (TableData)result;
}
/**
* Gets the URL used for the sample data.
* @return A URL for the sample data, expressed by default in the template as:
* rmi://@@hostname@@/@@servername@@#sampledata
*/
private Anchor getElementQuery(Node n) throws IOException {
try {
// borrow this info out of the tag map
URI graphUri = new URI(tagMap.get(GRAPH_TAG));
QueryParams params = new QueryParams();
params.add(GRAPH_ARG, graphUri.toString());
String text;
if (n instanceof URIReference) {
params.add(QUERY_RESOURCE_ARG, n.toString());
text = n.toString();
} else {
params.add(QUERY_LITERAL_ARG, parameterizeLiteral((Literal)n));
text = ((Literal)n).getEscapedForm();
}
return new Anchor(new URI(EXECUTE_LINK + "?" + params), text);
} catch (URISyntaxException e) {
throw (IOException) new IOException("Bad data returned from server").initCause(e);
}
}
/**
* Adds on a row without borders that links to the next page in this result.
* @param result The result being displayed.
* @param resultTable The existing page of data representing the result.
* @return The table with a new row appended to it, containing the required link.
* @throws IOException If there was an error structuring the table data.
*/
private Table appendNextPageLink(Answer result, Table resultTable) throws IOException {
if (isResuming(result)) {
TableRow tr = createNextLinkRow(result, resultTable.getWidth());
tr.addAttr(Attr.CLASS, "borderLess");
resultTable.add(tr);
}
return resultTable;
}
/**
* Creates a link that will take the user to the next page for this data.
* @param result The result to create the link for.
* @param width The width the row has to fill.
* @return A table row containing the link to the next unfinished result.
* @throws IOException If there was an error structuring the table data.
*/
private TableRow createNextLinkRow(Answer result, int width) throws IOException {
try {
QueryParams param = new QueryParams(RESULT_ORD_ARG, getNrToBeResumed(result));
Anchor a = new Anchor(new URI(EXECUTE_LINK + "?" + param), "Next page >");
a.addAttr(Attr.TITLE, "Forward to next page of results");
return new TableRow(new TableData(a).addAttr(Attr.COLSPAN, width));
} catch (URISyntaxException e) {
throw (IOException)
new IOException("Unabled to emit a relative URL: " + e.getMessage()).initCause(e);
}
}
/**
* Writes a top-level element to the output stream.
* @param elt The element to write.
* @throws IOException If there was an error getting the stream or using it.
*/
private void emitTopLevelElement(HtmlElement elt) throws IOException {
PrintWriter output = getOutput();
elt.sendTo(output);
output.append("\n");
}
/**
* Encode a Literal for use as a parameter in a query.
* @param l The literal to encode.
* @return An form of the literal that is safe to use in a parameter.
*/
private String parameterizeLiteral(Literal l) {
StringBuilder p = new StringBuilder("'");
p.append(l.getLexicalForm().replaceAll("'", "\\\\'")).append('\'');
URI datatype = l.getDatatypeURI();
String language = l.getLanguage();
if (datatype != null) {
p.append("^^<").append(datatype).append(">");
} else if (language != null && !language.equals("")) {
p.append("@").append(language);
}
return p.toString();
}
/**
* Gets the PrintWriter for this page.
* @return The print writer to use for this page.
* @throws IOException If there was a problem getting the object.
*/
private PrintWriter getOutput() throws IOException {
if (out == null) out = response.getWriter();
return out;
}
/**
* Determines how many results are to be resumed.
* @return <code>true</code> if this result has already been partly resumed.
*/
private int getNrToBeResumed(Answer ans) {
if (unfinishedResults == null) throw new IllegalStateException("Should not be creating a link to a result when that result was not saved.");
Iterator<Answer> answers = unfinishedResults.keySet().iterator();
// should either be the first one added, or the last one
return (answers.next() == ans) ? 1 : unfinishedResults.size();
}
/**
* Tests a result to see it is being resumed.
* @param result The Answer to test.
* @return <code>true</code> if this result has already been partly resumed.
*/
private boolean isResuming(Answer result) {
return (unfinishedResults != null) && unfinishedResults.keySet().contains(result);
}
/**
* Marks a result as finished.
* @param result The Answer that just finished.
* @throws TuplesException Error while cleaning up the result.
*/
private void resultFinished(Answer result) throws TuplesException {
if (unfinishedResults != null) unfinishedResults.remove(result);
result.close();
}
/**
* Adds an unfinished Answer to the current session for later display. The Answer is not
* added if all the rows have been displayed.
* @param result The Answer to add to the session.
* @param numDisplayed The number of rows that have just been displayed from the result.
* @param cmd The Command associated with the Answer in <var>result</var>.
* @throws TuplesException If it is not possible to get the number of remaining rows.
*/
private void addUnfinishedResult(Answer result, int numDisplayed, Command cmd) throws TuplesException {
// Determine how many results have yet to be displayed from this Answer
long remainingRows;
if (unfinishedResults == null) remainingRows = result.getRowCount();
else {
// get the remaining from the session, or use the full value if not in the session
Pair<Long,Command> lastRemainingRows = unfinishedResults.get(result);
remainingRows = (lastRemainingRows == null) ? result.getRowCount() : lastRemainingRows.first();
}
// just diplayed a page, so decrement by a page
remainingRows -= numDisplayed;
if (remainingRows < 0) throw new IllegalStateException("Cannot display more rows than are available.");
// No more to display, so clean up and leave
if (remainingRows == 0) {
result.close();
unfinishedResults.remove(result);
return;
}
// Remember that there is more for this result
if (unfinishedResults == null) {
unfinishedResults = new LinkedHashMap<Answer,Pair<Long,Command>>();
request.getSession().setAttribute(UNFINISHED_RESULTS, unfinishedResults);
}
unfinishedResults.put(result, new Pair<Long,Command>(remainingRows, cmd));
}
}