/**
* Copyright (C) 2010 Orbeon, Inc.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU Lesser General Public License as published by the Free Software Foundation; either version
* 2.1 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Lesser General Public License for more details.
*
* The full text of the license is available at http://www.gnu.org/copyleft/lesser.html
*/
package org.orbeon.oxf.xforms;
import org.orbeon.dom.Element;
import org.orbeon.oxf.common.OXFException;
import org.orbeon.oxf.common.OrbeonLocationException;
import org.orbeon.oxf.common.ValidationException;
import org.orbeon.oxf.util.IndentedLogger;
import org.orbeon.oxf.util.XPath;
import org.orbeon.oxf.util.XPathCache;
import org.orbeon.oxf.xforms.analysis.ElementAnalysis;
import org.orbeon.oxf.xforms.analysis.VariableAnalysisTrait;
import org.orbeon.oxf.xforms.function.XFormsFunction;
import org.orbeon.oxf.xforms.model.RuntimeBind;
import org.orbeon.oxf.xforms.model.XFormsModel;
import org.orbeon.oxf.xforms.xbl.Scope;
import org.orbeon.oxf.xforms.xbl.XBLContainer;
import org.orbeon.oxf.xml.NamespaceMapping;
import org.orbeon.oxf.xml.TransformerUtils;
import org.orbeon.oxf.xml.dom4j.ExtendedLocationData;
import org.orbeon.oxf.xml.dom4j.LocationData;
import org.orbeon.saxon.om.Item;
import org.orbeon.saxon.om.NodeInfo;
import org.orbeon.saxon.om.ValueRepresentation;
import org.orbeon.saxon.tinytree.TinyBuilder;
import org.xml.sax.SAXException;
import scala.Option;
import javax.xml.transform.sax.TransformerHandler;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Handle a stack of XPath evaluation context information. This is used by controls (with one stack rooted at each
* XBLContainer), models, and actions.
*
* TODO: This has to go, and instead we will just use BindingContext.
*/
public class XFormsContextStack {
private static final NodeInfo DUMMY_CONTEXT;
static {
try {
final TinyBuilder treeBuilder = new TinyBuilder();
final TransformerHandler identity = TransformerUtils.getIdentityTransformerHandler(XPath.GlobalConfiguration());
identity.setResult(treeBuilder);
identity.startDocument();
identity.endDocument();
DUMMY_CONTEXT = treeBuilder.getCurrentRoot();
} catch (SAXException e) {
throw new OXFException(e);
}
}
// If there is no XPath context defined at the root (in the case there is no default XForms model/instance
// available), we should use an empty context. However, currently for non-relevance in particular we must not run
// expressions with an empty context. To allow running expressions at the root of a container without models, we
// create instead a context with an empty document node instead. This way there is a context for evaluation. In the
// future, we should allow running expressions with no context, possibly after statically checking that they do not
// depend on the context, as well as prevent evaluations within non-relevant content by other means.
// final List<Item> DEFAULT_CONTEXT = XFormsConstants.EMPTY_ITEM_LIST;
private static final List<Item> DEFAULT_CONTEXT = Collections.singletonList((Item) DUMMY_CONTEXT);
private final boolean keepLocationData = XFormsProperties.isKeepLocation();
public final XBLContainer container;
public final XFormsContainingDocument containingDocument;
private BindingContext parentBindingContext;
private BindingContext head = null;
// Constructor for XFormsModel and XBLContainer
public XFormsContextStack(XBLContainer container) {
this.container = container;
this.containingDocument = container.getContainingDocument();
}
// Constructor for XFormsActionInterpreter
public XFormsContextStack(XBLContainer container, BindingContext parentBindingContext) {
this.container = container;
this.containingDocument = this.container.getContainingDocument();
this.parentBindingContext = parentBindingContext;
// Push a copy of the parent binding
this.head = pushCopy(parentBindingContext);
}
// Push a copy of the current binding
public BindingContext pushCopy() {
return pushCopy(this.head);
}
private BindingContext pushCopy(BindingContext parent) {
this.head = new BindingContext(parent, parent.modelOpt(), parent.bind(), parent.nodeset(),
parent.position(), parent.elementId(), false, parent.controlElement(),
parent.locationData(), false, parent.contextItem(), parent.scope());
return this.head;
}
// For XBL/xxf:dynamic
public void setParentBindingContext(BindingContext parentBindingContext) {
this.parentBindingContext = parentBindingContext;
}
public XFormsFunction.Context getFunctionContext(String sourceEffectiveId) {
return getFunctionContext(sourceEffectiveId, this.head);
}
public XFormsFunction.Context getFunctionContext(String sourceEffectiveId, Object data) {
return getFunctionContext(sourceEffectiveId, this.head, data);
}
public XFormsFunction.Context getFunctionContext(String sourceEffectiveId, BindingContext binding) {
return new XFormsFunction.Context(container, binding, sourceEffectiveId, binding.modelOpt(), null);
}
public XFormsFunction.Context getFunctionContext(String sourceEffectiveId, BindingContext binding, Object data) {
return new XFormsFunction.Context(container, binding, sourceEffectiveId, binding.modelOpt(), data);
}
/**
* Reset the binding context to the root of the first model's first instance, or to the parent binding context.
*/
public BindingContext resetBindingContext() {
// Reset to default model (can be null)
resetBindingContext(container.getDefaultModel());
return this.head;
}
/**
* Reset the binding context to the root of the given model's first instance.
*/
public void resetBindingContext(XFormsModel model) {
if (model != null && model.getDefaultInstance() != null) {
// Push the default context if there is a model with an instance
final Item defaultNode = model.getDefaultInstance().rootElement();
final List<Item> defaultNodeset = Collections.singletonList(defaultNode);
this.head = new BindingContext(parentBindingContext, Option.apply(model), null, defaultNodeset, 1, null, true, null,
model.getDefaultInstance().getLocationData(), false, defaultNode, container.innerScope());
} else {
// Push empty context
this.head = defaultContext(parentBindingContext, container, model);
}
// Add model variables for default model
if (model != null)
model.setTopLevelVariables(evaluateModelVariables(model));
}
/**
* Return an empty context for the given model (which can be null).
*/
public static BindingContext defaultContext(BindingContext parentBindingContext, XBLContainer container, XFormsModel model) {
final List<Item> defaultContext = DEFAULT_CONTEXT;
return new BindingContext(parentBindingContext, Option.apply(model), null, defaultContext, defaultContext.size(), null, true, null,
(model != null) ? model.getLocationData() : null, false, null, container.innerScope());
}
// NOTE: This only scopes top-level model variables, but not binds-as-variables.
private Map<String, ValueRepresentation> evaluateModelVariables(XFormsModel model) {
// TODO: Check dirty flag to prevent needless re-evaluation
// All variables in the model are in scope for the nested binds and actions.
final List<VariableAnalysisTrait> variables = model.getStaticModel().jVariablesSeq();
if (! variables.isEmpty()) {
final Map<String, ValueRepresentation> variableInfos = new HashMap<String, ValueRepresentation>();
for (final VariableAnalysisTrait variable : variables)
variableInfos.put(variable.name(), scopeVariable(variable, model.getEffectiveId(), true).value());
final IndentedLogger indentedLogger = containingDocument.getIndentedLogger(XFormsModel.LOGGING_CATEGORY);
if (indentedLogger.isDebugEnabled())
indentedLogger.logDebug("", "evaluated model variables", "count", Integer.toString(variableInfos.size()));
// Remove extra bindings added and set all variables on the current binding context so that things are cleaner
for (int i = 0; i < variableInfos.size(); i++)
popBinding();
return variableInfos;
} else
return Collections.emptyMap();
}
public VariableNameValue scopeVariable(VariableAnalysisTrait staticVariable, String sourceEffectiveId, boolean handleNonFatal) {
// Create variable object
final Variable variable = new Variable(staticVariable, containingDocument);
// Find variable scope
final Scope newScope = ((ElementAnalysis) staticVariable).scope();
// Push the variable on the context stack. Note that we do as if each variable was a "parent" of the
// following controls and variables.
// NOTE: The value is computed immediately. We should use Expression objects and do lazy evaluation
// in the future.
// NOTE: We used to simply add variables to the current bindingContext, but this could cause issues
// because getVariableValue() can itself use variables declared previously. This would work at first,
// but because BindingContext caches variables in scope, after a first request for in-scope variables,
// further variables values could not be added. The method below temporarily adds more elements on the
// stack but it is safer.
getFunctionContext(sourceEffectiveId);
this.head = this.head.pushVariable((ElementAnalysis) staticVariable, variable.staticVariable().name(), variable.valueEvaluateIfNeeded(this, sourceEffectiveId, true, handleNonFatal), newScope);
return this.head.variable().get();
}
public BindingContext setBinding(BindingContext bindingContext) {
this.head = bindingContext;
return this.head;
}
/**
* Push an element containing either single-node or nodeset binding attributes.
*
* @param bindingElement current element containing node binding attributes
* @param sourceEffectiveId effective id of source control for id resolution of models and binds
* @param scope XBL scope
*/
public void pushBinding(Element bindingElement, String sourceEffectiveId, Scope scope) {
pushBinding(bindingElement, sourceEffectiveId, scope, true);
}
// NOTE: actions pass handleNonFatal = "false", other callers pass handleNonFatal = "true".
public void pushBinding(Element bindingElement, String sourceEffectiveId, Scope scope, boolean handleNonFatal) {
// TODO: move away from element and use static analysis information
pushBinding(
bindingElement.attributeValue(XFormsConstants.REF_QNAME),
bindingElement.attributeValue(XFormsConstants.CONTEXT_QNAME),
bindingElement.attributeValue(XFormsConstants.NODESET_QNAME),
bindingElement.attributeValue(XFormsConstants.MODEL_QNAME),
bindingElement.attributeValue(XFormsConstants.BIND_QNAME),
bindingElement,
container.getNamespaceMappings(bindingElement),
sourceEffectiveId,
scope,
handleNonFatal
);
}
private BindingContext getBindingContext(Scope scope) {
BindingContext bindingContext = this.head;
while (bindingContext.scope() != scope) {
bindingContext = bindingContext.parent();
// There must be a matching scope down the line
assert bindingContext != null;
}
return bindingContext;
}
// NOTE: actions pass handleNonFatal = "false", other callers pass handleNonFatal = "true".
public void pushBinding(
String ref,
String context,
String nodeset,
String modelId,
String bindId,
Element bindingElement,
NamespaceMapping bindingElementNamespaceMapping,
String sourceEffectiveId,
Scope scope,
boolean handleNonFatal) {
assert scope != null;
final LocationData locationData; {
if (keepLocationData && bindingElement != null)
locationData = new ExtendedLocationData((LocationData) bindingElement.getData(), "pushing XForms control binding", bindingElement);
else
locationData = null;
}
try {
// Handle scope
// The new binding evaluates against a base binding context which must be in the same scope
final BindingContext baseBindingContext = getBindingContext(scope);
// Handle model
final Option<XFormsModel> newModelOpt;
final boolean isNewModel;
if (modelId != null) {
final XBLContainer resolutionScopeContainer = container.findScopeRoot(scope);
final XFormsObject o = resolutionScopeContainer.resolveObjectById(sourceEffectiveId, modelId, null);
if (!(o instanceof XFormsModel)) {
// Invalid model id
// NOTE: We used to dispatch xforms-binding-exception, but we want to be able to recover
if (!handleNonFatal)
throw new ValidationException("Reference to non-existing model id: " + modelId, locationData);
// Default to not changing the model
newModelOpt = baseBindingContext.modelOpt();
isNewModel = false;
} else {
newModelOpt = Option.apply((XFormsModel) o);
// Don't say it's a new model unless it has really changed
isNewModel =
baseBindingContext.modelOpt().isEmpty() && newModelOpt.nonEmpty() ||
baseBindingContext.modelOpt().nonEmpty() && newModelOpt.isEmpty() || (
baseBindingContext.modelOpt().nonEmpty() &&
newModelOpt.nonEmpty() &&
baseBindingContext.modelOpt().get() != newModelOpt.get()
);
}
} else {
newModelOpt = baseBindingContext.modelOpt();
isNewModel = false;
}
// Handle nodeset
final boolean isNewBind;
final RuntimeBind bind;
final int newPosition;
final List<Item> newNodeset;
final boolean hasOverriddenContext;
final Item contextItem;
{
if (bindId != null) {
// Resolve the bind id to a nodeset
// NOTE: For now, only the top-level models in a resolution scope are considered
final XBLContainer resolutionScopeContainer = container.findScopeRoot(scope);
final XFormsObject o =
resolutionScopeContainer.resolveObjectById(sourceEffectiveId, bindId, baseBindingContext.getSingleItem());
if (o == null && resolutionScopeContainer.containsBind(bindId)) {
// The bind attribute was valid for this scope, but no runtime object was found for the bind
// This can happen e.g. if a nested bind is within a bind with an empty nodeset
bind = null;
newNodeset = XFormsConstants.EMPTY_ITEM_LIST;
hasOverriddenContext = false;
contextItem = null;
isNewBind = true;
newPosition = 0;
} else if (!(o instanceof RuntimeBind)) {
// The bind attribute did not resolve to a bind
// NOTE: We used to dispatch xforms-binding-exception, but we want to be able to recover
if (!handleNonFatal)
throw new ValidationException("Reference to non-existing bind id: " + bindId, locationData);
// Default to an empty binding
bind = null;
newNodeset = XFormsConstants.EMPTY_ITEM_LIST;
hasOverriddenContext = false;
contextItem = null;
isNewBind = true;
newPosition = 0;
} else {
bind = (RuntimeBind) o;
newNodeset = bind.items();
hasOverriddenContext = false;
contextItem = baseBindingContext.getSingleItem();
isNewBind = true;
newPosition = Math.min(newNodeset.size(), 1);
}
} else if (ref != null || nodeset != null) {
bind = null;
// Check whether there is an optional context (XForms 1.1, likely generalized in XForms 1.2)
final BindingContext evaluationContextBinding;
if (context != null) {
// Push model and context
pushTemporaryContext(this.head, baseBindingContext, baseBindingContext.getSingleItem());// provide context information for the context() function
pushBinding(null, null, context, modelId, null, null, bindingElementNamespaceMapping, sourceEffectiveId, scope, handleNonFatal);
hasOverriddenContext = true;
final BindingContext newBindingContext = this.head;
contextItem = newBindingContext.getSingleItem();
evaluationContextBinding = newBindingContext;
} else if (isNewModel) {
// Push model only
pushBinding(null, null, null, modelId, null, null, bindingElementNamespaceMapping, sourceEffectiveId, scope, handleNonFatal);
hasOverriddenContext = false;
final BindingContext newBindingContext = this.head;
contextItem = newBindingContext.getSingleItem();
evaluationContextBinding = newBindingContext;
} else {
hasOverriddenContext = false;
contextItem = baseBindingContext.getSingleItem();
evaluationContextBinding = baseBindingContext;
}
// if (false) {
// // NOTE: This is an attempt at allowing evaluating a binding even if no context is present.
// // But this doesn't work properly. E.g.:
// //
// // <xf:group ref="()">
// // <xf:input ref="."/>
// //
// // Above must end up with an empty binding for xf:input, while:
// //
// // <xf:group ref="()">
// // <xf:input ref="instance('foobar')"/>
// //
// // Above must end up with a non-empty binding IF it was to be evaluated.
// //
// // Now the second condition above should not happen anyway, because the content of the group
// // is non-relevant anyway. But we do have cases now where this happens, so we can't enable
// // the code below naively.
// //
// // We could enable it if we knew statically that the expression did not depend on the
// // context though, but right now we don't.
//
// final boolean isDefaultContext;
// final List<Item> evaluationNodeset;
// final int evaluationPosition;
// if (evaluationContextBinding.getNodeset().size() > 0) {
// isDefaultContext = false;
// evaluationNodeset = evaluationContextBinding.getNodeset();
// evaluationPosition = evaluationContextBinding.getPosition();
// } else {
// isDefaultContext = true;
// evaluationNodeset = DEFAULT_CONTEXT;
// evaluationPosition = 1;
// }
//
// if (!isDefaultContext) {
// // Provide context information for the context() function
// pushTemporaryContext(this.head, evaluationContextBinding, evaluationContextBinding.getSingleItem());
// }
//
// // Use updated binding context to set model
// functionContext.setModel(evaluationContextBinding.model());
//
// List<Item> result;
// try {
// result = XPathCache.evaluateKeepItems(evaluationNodeset, evaluationPosition,
// ref != null ? ref : nodeset, bindingElementNamespaceMapping, evaluationContextBinding.getInScopeVariables(), XFormsContainingDocument.getFunctionLibrary(),
// functionContext, null, locationData, containingDocument.getRequestStats().getReporter());
// } catch (Exception e) {
// if (handleNonFatal) {
// XFormsError.handleNonFatalXPathError(container, e);
// result = XFormsConstants.EMPTY_ITEM_LIST;
// } else {
// throw e;
// }
// }
// newNodeset = result;
//
//
// if (!isDefaultContext) {
// popBinding();
// }
// } else {
if (evaluationContextBinding.nodeset().size() > 0) {
// Evaluate new XPath in context if the current context is not empty
// TODO: in the future, we should allow null context for expressions that do not depend on the context
// NOTE: We prevent evaluation if the context was empty. However there are cases where this
// should be allowed, if the expression does not depend on the context. Ideally, we would know
// statically whether an expression depends on the context or not, and take separate action if
// that's the case. Currently, such an expression will produce an XPathException.
// It might be the case that when we implement non-evaluation of relevant subtrees, this won't
// be an issue anymore, and we can simply allow evaluation of such expressions. Otherwise,
// static analysis of expressions might provide enough information to handle the two situations.
final XFormsFunction.Context functionContext =
getFunctionContext(
sourceEffectiveId,
updateBindingWithContextItem(this.head, evaluationContextBinding, evaluationContextBinding.getSingleItem())
);
List<Item> result;
try {
result = XPathCache.evaluateKeepItems(
evaluationContextBinding.nodeset(),
evaluationContextBinding.position(),
ref != null ? ref : nodeset,
bindingElementNamespaceMapping,
evaluationContextBinding.getInScopeVariables(),
containingDocument.getFunctionLibrary(),
functionContext,
null,
locationData,
containingDocument.getRequestStats().getReporter()
);
} catch (Exception e) {
if (handleNonFatal) {
XFormsError.handleNonFatalXPathError(container, e);
result = XFormsConstants.EMPTY_ITEM_LIST;
} else {
throw e;
}
}
newNodeset = result;
} else {
// Otherwise we consider we can't evaluate
newNodeset = XFormsConstants.EMPTY_ITEM_LIST;
}
// }
// Restore optional context
if (context != null || isNewModel) {
popBinding();
if (context != null)
popBinding();
}
isNewBind = true;
newPosition = 1;
} else if (isNewModel && context == null) {
// Only the model has changed
bind = null;
final Option<BindingContext> modelBindingContextOpt = this.head.currentBindingContextForModel(newModelOpt);
if (modelBindingContextOpt.isDefined()) {
final BindingContext modelBindingContext = modelBindingContextOpt.get();
newNodeset = modelBindingContext.nodeset();
newPosition = modelBindingContext.position();
} else {
newNodeset = this.head.currentNodeset(newModelOpt);
newPosition = 1;
}
hasOverriddenContext = false;
contextItem = baseBindingContext.getSingleItem();
isNewBind = false;
} else if (context != null) {
bind = null;
// Only the context has changed, and possibly the model
pushBinding(null, null, context, modelId, null, null, bindingElementNamespaceMapping, sourceEffectiveId, scope, handleNonFatal);
{
newNodeset = this.head.nodeset();
newPosition = this.head.position();
isNewBind = false;
hasOverriddenContext = true;
contextItem = this.head.getSingleItem();
}
popBinding();
} else {
// No change to anything
bind = null;
isNewBind = false;
newNodeset = baseBindingContext.nodeset();
newPosition = baseBindingContext.position();
// We set a new context item as the context into which other attributes must be evaluated. E.g.:
//
// <xf:select1 ref="type">
// <xf:action ev:event="xforms-value-changed" if="context() = 'foobar'">
//
// In this case, you expect context() to be updated as follows.
//
hasOverriddenContext = false;
contextItem = baseBindingContext.getSingleItem();
}
}
// Push new context
final String bindingElementId = (bindingElement == null) ? null : XFormsUtils.getElementId(bindingElement);
this.head = new BindingContext(this.head, newModelOpt, bind, newNodeset, newPosition, bindingElementId, isNewBind,
bindingElement, locationData, hasOverriddenContext, contextItem, scope);
} catch (Exception e) {
if (bindingElement != null) {
throw OrbeonLocationException.wrapException(e, new ExtendedLocationData(locationData, "evaluating binding expression", bindingElement));
} else {
throw OrbeonLocationException.wrapException(e, new ExtendedLocationData(locationData, "evaluating binding expression",
bindingElement, new String[] { "ref", ref, "context", context, "nodeset", nodeset, "modelId", modelId, "bindId", bindId }));
}
}
}
private void pushTemporaryContext(BindingContext parent, BindingContext base, Item contextItem) {
this.head = updateBindingWithContextItem(parent, base, contextItem);
}
private BindingContext updateBindingWithContextItem(BindingContext parent, BindingContext base, Item contextItem) {
return new BindingContext(
parent,
base.modelOpt(),
null,
base.nodeset(),
base.position(),
base.elementId(),
false,
base.controlElement(),
base.locationData(),
false,
contextItem,
base.scope()
);
}
/**
* Push an iteration of the current node-set. Used for example by xf:repeat, xf:bind, iterate.
*
* @param currentPosition 1-based iteration index
*/
public BindingContext pushIteration(int currentPosition) {
final BindingContext currentBindingContext = this.head;
final List<Item> currentNodeset = currentBindingContext.nodeset();
// Set a new context item, although the context() function is never called on the iteration itself
final Item newContextItem;
if (currentNodeset.size() == 0)
newContextItem = null;
else
newContextItem = currentNodeset.get(currentPosition - 1);
this.head = new BindingContext(currentBindingContext, currentBindingContext.modelOpt(), null,
currentNodeset, currentPosition, currentBindingContext.elementId(), true, null, currentBindingContext.locationData(),
false, newContextItem, currentBindingContext.scope());
return this.head;
}
public BindingContext getCurrentBindingContext() {
return head;
}
public BindingContext popBinding() {
if (this.head.parent() == null)
throw new OXFException("Attempt to clear context stack.");
final BindingContext popped = this.head;
this.head = this.head.parent();
return popped;
}
}