1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.core.metapath;
7   
8   import java.io.IOException;
9   import java.io.UncheckedIOException;
10  import java.net.URI;
11  import java.time.Clock;
12  import java.time.Duration;
13  import java.time.LocalDateTime;
14  import java.time.ZoneId;
15  import java.time.ZoneOffset;
16  import java.time.ZonedDateTime;
17  import java.util.ArrayDeque;
18  import java.util.Collections;
19  import java.util.Deque;
20  import java.util.Map;
21  import java.util.concurrent.ConcurrentHashMap;
22  import java.util.stream.Collectors;
23  
24  import dev.metaschema.core.configuration.DefaultConfiguration;
25  import dev.metaschema.core.configuration.IConfiguration;
26  import dev.metaschema.core.configuration.IMutableConfiguration;
27  import dev.metaschema.core.metapath.function.CalledContext;
28  import dev.metaschema.core.metapath.function.DateTimeFunctionException;
29  import dev.metaschema.core.metapath.function.IFunction;
30  import dev.metaschema.core.metapath.function.IFunction.FunctionProperty;
31  import dev.metaschema.core.metapath.item.ISequence;
32  import dev.metaschema.core.metapath.item.atomic.IDayTimeDurationItem;
33  import dev.metaschema.core.metapath.item.node.IDocumentNodeItem;
34  import dev.metaschema.core.model.IUriResolver;
35  import dev.metaschema.core.qname.IEnhancedQName;
36  import dev.metaschema.core.util.ObjectUtils;
37  import edu.umd.cs.findbugs.annotations.NonNull;
38  import edu.umd.cs.findbugs.annotations.Nullable;
39  
40  // TODO: add support for in-scope namespaces
41  /**
42   * The implementation of a Metapath
43   * <a href="https://www.w3.org/TR/xpath-31/#eval_context">dynamic context</a>.
44   */
45  public class DynamicContext {
46  
47    @NonNull
48    private final Map<Integer, ISequence<?>> letVariableMap;
49    @NonNull
50    private final SharedState sharedState;
51    @Nullable
52    private final FocusContext focusContext;
53    @NonNull
54    private final Deque<IExpression> executionStack;
55  
56    /**
57     * Construct a new dynamic context with a default static context.
58     */
59    public DynamicContext() {
60      this(StaticContext.instance());
61    }
62  
63    /**
64     * Construct a new Metapath dynamic context using the provided static context.
65     *
66     * @param staticContext
67     *          the Metapath static context
68     */
69    public DynamicContext(@NonNull StaticContext staticContext) {
70      this.letVariableMap = new ConcurrentHashMap<>();
71      this.sharedState = new SharedState(staticContext);
72      this.focusContext = null;
73      this.executionStack = new ArrayDeque<>();
74    }
75  
76    private DynamicContext(@NonNull DynamicContext context) {
77      this(context, context.focusContext);
78    }
79  
80    private DynamicContext(@NonNull DynamicContext context, @Nullable FocusContext focusContext) {
81      this.letVariableMap = new ConcurrentHashMap<>(context.letVariableMap);
82      this.sharedState = context.sharedState;
83      this.focusContext = focusContext;
84      // Copy parent's stack so error traces show full call chain
85      this.executionStack = new ArrayDeque<>(context.executionStack);
86    }
87  
88    private static class SharedState {
89      @NonNull
90      private final StaticContext staticContext;
91      @NonNull
92      private final ZonedDateTime currentDateTime;
93      @NonNull
94      private final Map<URI, IDocumentNodeItem> availableDocuments;
95      @NonNull
96      private final Map<CalledContext, ISequence<?>> functionResultCache;
97      @Nullable
98      private CachingLoader documentLoader;
99      @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 }