using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json.Linq; namespace Unity.Pipeline.Editor.Commands.Batch { /// /// Resolves cross-operation result references in a batch operation's parameters (AUTHAPI-27). /// /// A string value that begins with a single '$' is a reference of the form /// "$<selector>" or "$<selector>.<jsonPath>", where /// <selector> names an earlier operation by its id or its 0-based index and /// <jsonPath> is a Newtonsoft SelectToken path into that op's result /// (e.g. "$0.instanceId", "$createHead.components[0].instanceId"). A leading /// "$$" escapes a literal '$'. References are backward-only: a selector must resolve /// to an operation strictly before the current one. /// /// References are resolved as WHOLE values (the entire string must be the reference), and the /// substituted JSON token preserves its type — so a numeric instanceId stays a number and /// deserializes into the target parameter (e.g. an ObjectRef) exactly as a literal would. /// The walk recurses through nested objects and arrays, so a reference can appear anywhere in the /// parameter tree. /// internal static class BatchReferenceResolver { /// /// Validate the reference topology of without substituting any /// values (used by dry run). Throws on an unknown selector, /// a forward/self reference, or a malformed reference. /// public static void Validate(JToken parameters, int currentIndex, IReadOnlyList operations, IReadOnlyDictionary idToIndex) { if (parameters == null) return; // No DeepClone: in validate mode (null resultsByIndex) TransformString always returns the // original token, so Walk never reassigns a child — the walk is read-only. Walk(parameters, currentIndex, operations, idToIndex, null, null); } /// /// Return a copy of with every reference replaced by the /// referenced value from . Throws /// on an unknown selector, a forward/self reference, a /// reference whose path does not resolve, or a reference into an operation that failed, was /// skipped, or returned no result (per-op outcomes come from ). /// The input token is not mutated. /// public static JObject Resolve(JObject parameters, int currentIndex, IReadOnlyList operations, IReadOnlyDictionary idToIndex, JToken[] resultsByIndex, IReadOnlyList outcomes) { if (parameters == null) return null; var clone = (JObject)parameters.DeepClone(); return (JObject)Walk(clone, currentIndex, operations, idToIndex, resultsByIndex, outcomes); } /// /// Recursively transform a token tree. is null in validate /// mode (topology check only) and non-null in resolve mode (value substitution). /// private static JToken Walk(JToken node, int currentIndex, IReadOnlyList operations, IReadOnlyDictionary idToIndex, JToken[] resultsByIndex, IReadOnlyList outcomes) { switch (node.Type) { case JTokenType.Object: foreach (var prop in ((JObject)node).Properties().ToList()) { var replaced = Walk(prop.Value, currentIndex, operations, idToIndex, resultsByIndex, outcomes); if (!ReferenceEquals(replaced, prop.Value)) prop.Value = replaced; } return node; case JTokenType.Array: var arr = (JArray)node; for (int i = 0; i < arr.Count; i++) { var child = arr[i]; var replaced = Walk(child, currentIndex, operations, idToIndex, resultsByIndex, outcomes); if (!ReferenceEquals(replaced, child)) arr[i] = replaced; } return node; case JTokenType.String: return TransformString((string)node, node, currentIndex, operations, idToIndex, resultsByIndex, outcomes); default: return node; } } private static JToken TransformString(string s, JToken original, int currentIndex, IReadOnlyList operations, IReadOnlyDictionary idToIndex, JToken[] resultsByIndex, IReadOnlyList outcomes) { if (string.IsNullOrEmpty(s) || s[0] != '$') return original; // not a reference // "$$..." escapes a literal '$'. Only substitute when actually resolving; validation // has nothing to check for a literal. if (s.Length >= 2 && s[1] == '$') return resultsByIndex == null ? original : (JToken)new JValue(s.Substring(1)); var rest = s.Substring(1); var dot = rest.IndexOf('.'); var selector = dot < 0 ? rest : rest.Substring(0, dot); var path = dot < 0 ? null : rest.Substring(dot + 1); if (string.IsNullOrEmpty(selector)) throw new ArgumentException( $"Invalid reference '{s}': missing operation selector after '$'. " + "Use \"$.\", or \"$$\" for a literal '$'."); var targetIndex = ResolveSelector(selector, operations, idToIndex); if (targetIndex < 0) throw new ArgumentException( $"Reference '{s}' points to unknown operation '{selector}'. " + "A reference must name an earlier operation by its id or 0-based index."); if (targetIndex >= currentIndex) throw new ArgumentException( $"Reference '{s}' is a forward reference to operation '{selector}' (index {targetIndex}); " + "only backward references to earlier operations are allowed."); // Validate mode: topology is fine, and there is no result to substitute yet. if (resultsByIndex == null) return original; // A failed or skipped operation has no result to reference — substituting null silently // would feed a bogus value into this op's parameters. Error explicitly instead. In the // real Execute() loop only Success=false outcomes are observable here (under // on_error=continue — earlier failures don't stop the batch); Skipped entries are // appended AFTER the loop, so the "was skipped" wording is defensive, reachable only // when the resolver is driven directly (unit tests / future callers). var outcome = outcomes != null && targetIndex < outcomes.Count ? outcomes[targetIndex] : null; if (outcome != null && !outcome.Success) { var reason = outcome.Skipped == true ? "was skipped" : "failed"; throw new ArgumentException( $"Reference '{s}' cannot be resolved: operation '{selector}' {reason}; " + "its result cannot be referenced."); } // Distinct from failure: the op SUCCEEDED but produced nothing to reference (void/null // result, or a result that could not be captured as a token). var opResult = resultsByIndex[targetIndex]; if (opResult == null || opResult.Type == JTokenType.Null) throw new ArgumentException( $"Reference '{s}' cannot be resolved: operation '{selector}' returned no result."); var picked = string.IsNullOrEmpty(path) ? opResult : opResult.SelectToken(path); if (picked == null) throw new ArgumentException( $"Reference '{s}' did not resolve: operation '{selector}' result has no field '{path}'."); return picked.DeepClone(); } /// /// Resolve a selector to an operation index: an id match takes precedence over a numeric /// index. Returns -1 when the selector names no operation. /// private static int ResolveSelector(string selector, IReadOnlyList operations, IReadOnlyDictionary idToIndex) { if (idToIndex != null && idToIndex.TryGetValue(selector, out var byId)) return byId; if (int.TryParse(selector, out var idx) && idx >= 0 && idx < operations.Count) return idx; return -1; } } }