001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.core.metapath;
007
008import java.io.IOException;
009import java.io.UncheckedIOException;
010import java.net.URI;
011import java.time.Clock;
012import java.time.Duration;
013import java.time.LocalDateTime;
014import java.time.ZoneId;
015import java.time.ZoneOffset;
016import java.time.ZonedDateTime;
017import java.util.ArrayDeque;
018import java.util.Collections;
019import java.util.Deque;
020import java.util.Map;
021import java.util.concurrent.ConcurrentHashMap;
022import java.util.stream.Collectors;
023
024import dev.metaschema.core.configuration.DefaultConfiguration;
025import dev.metaschema.core.configuration.IConfiguration;
026import dev.metaschema.core.configuration.IMutableConfiguration;
027import dev.metaschema.core.metapath.function.CalledContext;
028import dev.metaschema.core.metapath.function.DateTimeFunctionException;
029import dev.metaschema.core.metapath.function.IFunction;
030import dev.metaschema.core.metapath.function.IFunction.FunctionProperty;
031import dev.metaschema.core.metapath.item.ISequence;
032import dev.metaschema.core.metapath.item.atomic.IDayTimeDurationItem;
033import dev.metaschema.core.metapath.item.node.IDocumentNodeItem;
034import dev.metaschema.core.model.IUriResolver;
035import dev.metaschema.core.qname.IEnhancedQName;
036import dev.metaschema.core.util.ObjectUtils;
037import edu.umd.cs.findbugs.annotations.NonNull;
038import edu.umd.cs.findbugs.annotations.Nullable;
039
040// TODO: add support for in-scope namespaces
041/**
042 * The implementation of a Metapath
043 * <a href="https://www.w3.org/TR/xpath-31/#eval_context">dynamic context</a>.
044 */
045public class DynamicContext {
046
047  @NonNull
048  private final Map<Integer, ISequence<?>> letVariableMap;
049  @NonNull
050  private final SharedState sharedState;
051  @Nullable
052  private final FocusContext focusContext;
053  @NonNull
054  private final Deque<IExpression> executionStack;
055
056  /**
057   * Construct a new dynamic context with a default static context.
058   */
059  public DynamicContext() {
060    this(StaticContext.instance());
061  }
062
063  /**
064   * Construct a new Metapath dynamic context using the provided static context.
065   *
066   * @param staticContext
067   *          the Metapath static context
068   */
069  public DynamicContext(@NonNull StaticContext staticContext) {
070    this.letVariableMap = new ConcurrentHashMap<>();
071    this.sharedState = new SharedState(staticContext);
072    this.focusContext = null;
073    this.executionStack = new ArrayDeque<>();
074  }
075
076  private DynamicContext(@NonNull DynamicContext context) {
077    this(context, context.focusContext);
078  }
079
080  private DynamicContext(@NonNull DynamicContext context, @Nullable FocusContext focusContext) {
081    this.letVariableMap = new ConcurrentHashMap<>(context.letVariableMap);
082    this.sharedState = context.sharedState;
083    this.focusContext = focusContext;
084    // Copy parent's stack so error traces show full call chain
085    this.executionStack = new ArrayDeque<>(context.executionStack);
086  }
087
088  private static class SharedState {
089    @NonNull
090    private final StaticContext staticContext;
091    @NonNull
092    private final ZonedDateTime currentDateTime;
093    @NonNull
094    private final Map<URI, IDocumentNodeItem> availableDocuments;
095    @NonNull
096    private final Map<CalledContext, ISequence<?>> functionResultCache;
097    @Nullable
098    private CachingLoader documentLoader;
099    @NonNull
100    private final IMutableConfiguration<MetapathEvaluationFeature<?>> configuration;
101    @NonNull
102    private ZoneId implicitTimeZone;
103
104    public SharedState(@NonNull StaticContext staticContext) {
105      this.staticContext = staticContext;
106
107      Clock clock = Clock.systemDefaultZone();
108
109      this.implicitTimeZone = ObjectUtils.notNull(clock.getZone());
110
111      this.currentDateTime = ObjectUtils.notNull(ZonedDateTime.now(clock));
112      this.availableDocuments = new ConcurrentHashMap<>();
113      this.functionResultCache = new ConcurrentHashMap<>();
114      this.configuration = new DefaultConfiguration<>();
115      this.configuration.enableFeature(MetapathEvaluationFeature.METAPATH_EVALUATE_PREDICATES);
116    }
117  }
118
119  /**
120   * Generate a new dynamic context that is a copy of this dynamic context.
121   * <p>
122   * This method can be used to create a new sub-context where changes can be made
123   * without affecting this context. This is useful for setting information that
124   * is only used in a limited evaluation sub-scope, such as for handling variable
125   * assignment.
126   * <p>
127   * The focus context from this context is preserved in the new sub-context,
128   * allowing nested expressions to access the enclosing focus (e.g., for
129   * {@code position()} and {@code last()} calls within variable binding scopes).
130   *
131   * @return a new dynamic context
132   */
133  @NonNull
134  public DynamicContext subContext() {
135    return new DynamicContext(this);
136  }
137
138  /**
139   * Generate a new dynamic context with the specified focus context.
140   * <p>
141   * This method is used by predicate expressions to establish a new focus for
142   * evaluating predicates. The focus context provides the information needed by
143   * {@code fn:position()} and {@code fn:last()}.
144   *
145   * @param focusContext
146   *          the focus context for the new sub-context
147   * @return a new dynamic context with the specified focus context
148   */
149  @NonNull
150  public DynamicContext subContext(@NonNull FocusContext focusContext) {
151    return new DynamicContext(this, focusContext);
152  }
153
154  /**
155   * Get the focus context for this dynamic context.
156   * <p>
157   * The focus context contains the context item, position, and size as defined in
158   * the <a href="https://www.w3.org/TR/xpath-31/#eval_context">XPath 3.1
159   * evaluation context</a>.
160   *
161   * @return the focus context, or {@code null} if no focus context is established
162   */
163  @Nullable
164  public FocusContext getFocusContext() {
165    return focusContext;
166  }
167
168  /**
169   * Get the static context associated with this dynamic context.
170   *
171   * @return the associated static context
172   */
173  @NonNull
174  public StaticContext getStaticContext() {
175    return sharedState.staticContext;
176  }
177
178  /**
179   * Get the default time zone used for evaluation.
180   *
181   * @return the time zone identifier object
182   */
183  @NonNull
184  public ZoneId getImplicitTimeZone() {
185    return sharedState.implicitTimeZone;
186  }
187
188  /**
189   * Get the default time zone used for evaluation.
190   *
191   * @return the time zone identifier object
192   */
193  @NonNull
194  public IDayTimeDurationItem getImplicitTimeZoneAsDayTimeDuration() {
195    LocalDateTime referenceDateTime = MetapathConstants.REFERENCE_DATE_TIME.asLocalDateTime();
196    ZonedDateTime reference = referenceDateTime.atZone(getImplicitTimeZone());
197    ZonedDateTime referenceZ = referenceDateTime.atZone(ZoneOffset.UTC);
198
199    return IDayTimeDurationItem.valueOf(ObjectUtils.notNull(
200        Duration.between(
201            reference,
202            referenceZ)));
203  }
204
205  /**
206   * Set the implicit timezone to the provided value.
207   * <p>
208   * Note: This value should only be adjusted when the context is first created.
209   * Once the context is used, this value is expected to be stable.
210   *
211   * @param timezone
212   *          the timezone to use
213   */
214  public void setImplicitTimeZone(@NonNull ZoneId timezone) {
215    sharedState.implicitTimeZone = timezone;
216  }
217
218  /**
219   * Set the implicit timezone to the provided value.
220   * <p>
221   * Note: This value should only be adjusted when the context is first created.
222   * Once the context is used, this value is expected to be stable.
223   *
224   * @param offset
225   *          the offset which must be &gt;= -PT14H and &lt;= PT13H
226   * @throws DateTimeFunctionException
227   *           with the code
228   *           {@link DateTimeFunctionException#INVALID_TIME_ZONE_VALUE_ERROR} if
229   *           the offset is &lt; -PT14H or &gt; PT14H
230   */
231  public void setImplicitTimeZone(@NonNull IDayTimeDurationItem offset) {
232    setImplicitTimeZone(offset.asZoneOffset());
233  }
234
235  /**
236   * Get the current date and time.
237   *
238   * @return the current date and time
239   */
240  @NonNull
241  public ZonedDateTime getCurrentDateTime() {
242    return sharedState.currentDateTime;
243  }
244
245  /**
246   * Get the mapping of loaded documents from the document URI to the document
247   * node.
248   *
249   * @return the map of document URIs to document nodes
250   */
251  @SuppressWarnings("null")
252  @NonNull
253  public Map<URI, IDocumentNodeItem> getAvailableDocuments() {
254    return Collections.unmodifiableMap(sharedState.availableDocuments);
255  }
256
257  /**
258   * Get the document loader assigned to this dynamic context.
259   *
260   * @return the loader
261   * @throws ContextAbsentDynamicMetapathException
262   *           if a document loader is not configured for this dynamic context
263   */
264  @NonNull
265  public IDocumentLoader getDocumentLoader() {
266    IDocumentLoader retval = sharedState.documentLoader;
267    if (retval == null) {
268      throw new UnsupportedOperationException(
269          "No document loader configured for the dynamic context. Use setDocumentLoader(loader) to confgure one.");
270    }
271    return retval;
272  }
273
274  /**
275   * Assign a document loader to this dynamic context.
276   *
277   * @param documentLoader
278   *          the document loader to assign
279   */
280  public void setDocumentLoader(@NonNull IDocumentLoader documentLoader) {
281    this.sharedState.documentLoader = new CachingLoader(documentLoader);
282  }
283
284  /**
285   * Get the cached function call result for evaluating a function that has the
286   * property {@link FunctionProperty#DETERMINISTIC}.
287   *
288   * @param callingContext
289   *          the function calling context information that distinguishes the call
290   *          from any other call
291   * @return the cached result sequence for the function call
292   */
293  @Nullable
294  public ISequence<?> getCachedResult(@NonNull CalledContext callingContext) {
295    return sharedState.functionResultCache.get(callingContext);
296  }
297
298  /**
299   * Cache a function call result for a that has the property
300   * {@link FunctionProperty#DETERMINISTIC}.
301   *
302   * @param callingContext
303   *          the calling context information that distinguishes the call from any
304   *          other call
305   * @param result
306   *          the function call result
307   */
308  public void cacheResult(@NonNull CalledContext callingContext, @NonNull ISequence<?> result) {
309    ISequence<?> old = sharedState.functionResultCache.put(callingContext, result);
310    assert old == null;
311  }
312
313  /**
314   * Used to disable the evaluation of predicate expressions during Metapath
315   * evaluation.
316   * <p>
317   * This can be useful for determining the potential targets identified by a
318   * Metapath expression as a partial evaluation, without evaluating that these
319   * targets match the predicate.
320   *
321   * @return this dynamic context
322   */
323  @NonNull
324  public DynamicContext disablePredicateEvaluation() {
325    this.sharedState.configuration.disableFeature(MetapathEvaluationFeature.METAPATH_EVALUATE_PREDICATES);
326    return this;
327  }
328
329  /**
330   * Used to enable the evaluation of predicate expressions during Metapath
331   * evaluation.
332   * <p>
333   * This is the default behavior if unchanged.
334   *
335   * @return this dynamic context
336   */
337  @NonNull
338  public DynamicContext enablePredicateEvaluation() {
339    this.sharedState.configuration.enableFeature(MetapathEvaluationFeature.METAPATH_EVALUATE_PREDICATES);
340    return this;
341  }
342
343  /**
344   * Used to make atomization of node items that have no associated typed value
345   * yield a {@code null} atomic value instead of raising
346   * {@link dev.metaschema.core.metapath.function.InvalidTypeFunctionException}.
347   * <p>
348   * Intended for callers that traverse an
349   * {@link dev.metaschema.core.metapath.item.node.IModuleNodeItem} graph and want
350   * downstream function calls to degrade gracefully when they receive a no-data
351   * flag rather than an instance value.
352   *
353   * @return this dynamic context
354   */
355  @NonNull
356  public DynamicContext enableAtomizeNoDataAsEmpty() {
357    this.sharedState.configuration.enableFeature(MetapathEvaluationFeature.METAPATH_ATOMIZE_NO_DATA_AS_EMPTY);
358    return this;
359  }
360
361  /**
362   * Used to restore the default behavior of raising
363   * {@link dev.metaschema.core.metapath.function.InvalidTypeFunctionException}
364   * when a node item that has no typed value is atomized.
365   *
366   * @return this dynamic context
367   */
368  @NonNull
369  public DynamicContext disableAtomizeNoDataAsEmpty() {
370    this.sharedState.configuration.disableFeature(MetapathEvaluationFeature.METAPATH_ATOMIZE_NO_DATA_AS_EMPTY);
371    return this;
372  }
373
374  /**
375   * Get the Metapath evaluation configuration.
376   *
377   * @return the configuration
378   */
379  @NonNull
380  public IConfiguration<MetapathEvaluationFeature<?>> getConfiguration() {
381    return sharedState.configuration;
382  }
383
384  /**
385   * Get the sequence value assigned to a let variable with the provided qualified
386   * name.
387   *
388   * @param name
389   *          the variable qualified name
390   * @return the non-null variable value
391   * @throws DynamicMetapathException
392   *           of the variable has not been assigned or if the variable value is
393   *           {@code null}
394   */
395  @NonNull
396  public ISequence<?> getVariableValue(@NonNull IEnhancedQName name) {
397    ISequence<?> retval = letVariableMap.get(name.getIndexPosition());
398    if (retval == null) {
399      throw new StaticMetapathException(
400          StaticMetapathException.NOT_DEFINED,
401          String.format("Variable '%s' not defined in the dynamic context.", name))
402              .registerEvaluationContext(this);
403    }
404    return retval;
405  }
406
407  /**
408   * Get the function with the provided name and arity.
409   *
410   * @param name
411   *          the requested function's qualified name
412   * @param arity
413   *          the number of arguments in the requested function
414   * @return the function
415   * @throws StaticMetapathException
416   *           with the code {@link StaticMetapathException#NO_FUNCTION_MATCH} if
417   *           a matching function was not found
418   */
419  @NonNull
420  public IFunction lookupFunction(@NonNull IEnhancedQName name, int arity) {
421    return getStaticContext().lookupFunction(name, arity);
422  }
423
424  /**
425   * Bind the variable {@code name} to the sequence {@code value}.
426   *
427   * @param name
428   *          the name of the variable to bind
429   * @param boundValue
430   *          the value to bind to the variable
431   * @return this dynamic context
432   */
433  @NonNull
434  public DynamicContext bindVariableValue(@NonNull IEnhancedQName name, @NonNull ISequence<?> boundValue) {
435    letVariableMap.put(name.getIndexPosition(), boundValue);
436    return this;
437  }
438
439  /**
440   * Push the current expression under evaluation to the execution queue.
441   *
442   * @param expression
443   *          the expression to push
444   */
445  public void pushExecutionStack(@NonNull IExpression expression) {
446    this.executionStack.push(expression);
447  }
448
449  /**
450   * Pop the expression that was under evaluation from the execution queue.
451   *
452   * @param expression
453   *          the expected expression to be popped
454   */
455  public void popExecutionStack(@NonNull IExpression expression) {
456    IExpression popped = this.executionStack.pop();
457    if (!expression.equals(popped)) {
458      throw new IllegalStateException("Popped expression does not match expected expression");
459    }
460  }
461
462  /**
463   * Return a copy of the current execution stack.
464   *
465   * @return the execution stack
466   */
467  @NonNull
468  public Deque<IExpression> getExecutionStack() {
469    return new ArrayDeque<>(this.executionStack);
470  }
471
472  /**
473   * Provides a formatted stack trace.
474   *
475   * @return the formatted stack trace
476   */
477  @NonNull
478  public String formatExecutionStackTrace() {
479    return ObjectUtils.notNull(getExecutionStack().stream()
480        .map(IExpression::toCSTString)
481        .collect(Collectors.joining("\n-> ")));
482  }
483
484  private class CachingLoader implements IDocumentLoader {
485    @NonNull
486    private final IDocumentLoader proxy;
487
488    public CachingLoader(@NonNull IDocumentLoader proxy) {
489      this.proxy = proxy;
490    }
491
492    @Override
493    public IUriResolver getUriResolver() {
494      return new ContextUriResolver();
495    }
496
497    @Override
498    public void setUriResolver(@NonNull IUriResolver resolver) {
499      // we delegate to the document loader proxy, so the resolver should be set there
500      throw new UnsupportedOperationException("Set the resolver on the proxy");
501    }
502
503    @NonNull
504    protected IDocumentLoader getProxiedDocumentLoader() {
505      return proxy;
506    }
507
508    @Override
509    public IDocumentNodeItem loadAsNodeItem(URI uri) throws IOException {
510      URI normalizedUri = uri.normalize();
511      try {
512        return sharedState.availableDocuments.computeIfAbsent(normalizedUri, key -> {
513          try {
514            return getProxiedDocumentLoader().loadAsNodeItem(key);
515          } catch (IOException e) {
516            throw new UncheckedIOException(e);
517          }
518        });
519      } catch (UncheckedIOException e) {
520        throw e.getCause();
521      }
522    }
523
524    public class ContextUriResolver implements IUriResolver {
525
526      /**
527       * {@inheritDoc}
528       * <p>
529       * This method first resolves the provided URI against the static context's base
530       * URI.
531       */
532      @Override
533      public URI resolve(URI uri) {
534        URI baseUri = getStaticContext().getBaseUri();
535
536        URI resolvedUri;
537        if (baseUri == null) {
538          resolvedUri = uri;
539        } else {
540          resolvedUri = ObjectUtils.notNull(baseUri.resolve(uri));
541        }
542
543        IUriResolver resolver = getProxiedDocumentLoader().getUriResolver();
544        return resolver == null ? resolvedUri : resolver.resolve(resolvedUri);
545      }
546    }
547  }
548}