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    * Get the Metapath evaluation configuration.
345    *
346    * @return the configuration
347    */
348   @NonNull
349   public IConfiguration<MetapathEvaluationFeature<?>> getConfiguration() {
350     return sharedState.configuration;
351   }
352 
353   /**
354    * Get the sequence value assigned to a let variable with the provided qualified
355    * name.
356    *
357    * @param name
358    *          the variable qualified name
359    * @return the non-null variable value
360    * @throws DynamicMetapathException
361    *           of the variable has not been assigned or if the variable value is
362    *           {@code null}
363    */
364   @NonNull
365   public ISequence<?> getVariableValue(@NonNull IEnhancedQName name) {
366     ISequence<?> retval = letVariableMap.get(name.getIndexPosition());
367     if (retval == null) {
368       throw new StaticMetapathException(
369           StaticMetapathException.NOT_DEFINED,
370           String.format("Variable '%s' not defined in the dynamic context.", name))
371               .registerEvaluationContext(this);
372     }
373     return retval;
374   }
375 
376   /**
377    * Get the function with the provided name and arity.
378    *
379    * @param name
380    *          the requested function's qualified name
381    * @param arity
382    *          the number of arguments in the requested function
383    * @return the function
384    * @throws StaticMetapathException
385    *           with the code {@link StaticMetapathException#NO_FUNCTION_MATCH} if
386    *           a matching function was not found
387    */
388   @NonNull
389   public IFunction lookupFunction(@NonNull IEnhancedQName name, int arity) {
390     return getStaticContext().lookupFunction(name, arity);
391   }
392 
393   /**
394    * Bind the variable {@code name} to the sequence {@code value}.
395    *
396    * @param name
397    *          the name of the variable to bind
398    * @param boundValue
399    *          the value to bind to the variable
400    * @return this dynamic context
401    */
402   @NonNull
403   public DynamicContext bindVariableValue(@NonNull IEnhancedQName name, @NonNull ISequence<?> boundValue) {
404     letVariableMap.put(name.getIndexPosition(), boundValue);
405     return this;
406   }
407 
408   /**
409    * Push the current expression under evaluation to the execution queue.
410    *
411    * @param expression
412    *          the expression to push
413    */
414   public void pushExecutionStack(@NonNull IExpression expression) {
415     this.executionStack.push(expression);
416   }
417 
418   /**
419    * Pop the expression that was under evaluation from the execution queue.
420    *
421    * @param expression
422    *          the expected expression to be popped
423    */
424   public void popExecutionStack(@NonNull IExpression expression) {
425     IExpression popped = this.executionStack.pop();
426     if (!expression.equals(popped)) {
427       throw new IllegalStateException("Popped expression does not match expected expression");
428     }
429   }
430 
431   /**
432    * Return a copy of the current execution stack.
433    *
434    * @return the execution stack
435    */
436   @NonNull
437   public Deque<IExpression> getExecutionStack() {
438     return new ArrayDeque<>(this.executionStack);
439   }
440 
441   /**
442    * Provides a formatted stack trace.
443    *
444    * @return the formatted stack trace
445    */
446   @NonNull
447   public String formatExecutionStackTrace() {
448     return ObjectUtils.notNull(getExecutionStack().stream()
449         .map(IExpression::toCSTString)
450         .collect(Collectors.joining("\n-> ")));
451   }
452 
453   private class CachingLoader implements IDocumentLoader {
454     @NonNull
455     private final IDocumentLoader proxy;
456 
457     public CachingLoader(@NonNull IDocumentLoader proxy) {
458       this.proxy = proxy;
459     }
460 
461     @Override
462     public IUriResolver getUriResolver() {
463       return new ContextUriResolver();
464     }
465 
466     @Override
467     public void setUriResolver(@NonNull IUriResolver resolver) {
468       // we delegate to the document loader proxy, so the resolver should be set there
469       throw new UnsupportedOperationException("Set the resolver on the proxy");
470     }
471 
472     @NonNull
473     protected IDocumentLoader getProxiedDocumentLoader() {
474       return proxy;
475     }
476 
477     @Override
478     public IDocumentNodeItem loadAsNodeItem(URI uri) throws IOException {
479       URI normalizedUri = uri.normalize();
480       try {
481         return sharedState.availableDocuments.computeIfAbsent(normalizedUri, key -> {
482           try {
483             return getProxiedDocumentLoader().loadAsNodeItem(key);
484           } catch (IOException e) {
485             throw new UncheckedIOException(e);
486           }
487         });
488       } catch (UncheckedIOException e) {
489         throw e.getCause();
490       }
491     }
492 
493     public class ContextUriResolver implements IUriResolver {
494 
495       /**
496        * {@inheritDoc}
497        * <p>
498        * This method first resolves the provided URI against the static context's base
499        * URI.
500        */
501       @Override
502       public URI resolve(URI uri) {
503         URI baseUri = getStaticContext().getBaseUri();
504 
505         URI resolvedUri;
506         if (baseUri == null) {
507           resolvedUri = uri;
508         } else {
509           resolvedUri = ObjectUtils.notNull(baseUri.resolve(uri));
510         }
511 
512         IUriResolver resolver = getProxiedDocumentLoader().getUriResolver();
513         return resolver == null ? resolvedUri : resolver.resolve(resolvedUri);
514       }
515     }
516   }
517 }