using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor.Authoring; using UnityEditor; #if UNITY_6000_5_OR_NEWER using Unity.Scripting.LifecycleManagement; #endif namespace Unity.Pipeline.Editor.Commands.Batch { /// /// The batch command (AUTHAPI-27): transactional multi-operation execution with cross-op /// result references, atomic Undo, and dry-run. /// /// WHY it exists: a "create → add components → wire fields" job is otherwise 10–90 sequential /// calls at ~0.8s each, and eval — the only alternative today — leaves partial state when it /// throws mid-way, which is unacceptable on user-owned scenes. batch collapses the round /// trips AND adds what eval structurally lacks: atomicity/rollback, dry-run validation, and /// cross-step references. /// /// Scope guard (epic AUTHAPI-24 design principle): batch is transactional object CRUD with /// references. It deliberately has NO invoke/loop/conditional op types — that path reinvents /// eval inside JSON and collapses the auditability story. Arbitrary logic belongs in /// run_script (code as files on disk), not here. /// /// Dispatch reuse: each sub-operation runs through /// , the exact parameter /// extraction / required-validation / reflection-invoke path /api/exec uses, so every per-op /// result is shaped identically to running that command standalone. /// /// Atomicity: the whole batch runs inside one , so all ops /// (each of which registers its own Undo operations) collapse into a single Editor Undo step. When /// transactional (the default) and an op fails under on_error=abort, every applied op /// is reverted with — the scene returns to its /// pre-batch state and there is nothing left on the Undo stack. Rollback is only real for /// Undo-tracked mutations, which is why commands that mutate state OUTSIDE the Undo system /// () are rejected in a transactional batch — see that field's /// doc for the v1 caveat. /// /// Timing: the batch executes in a single main-thread turn (no frame-spanning), so the /// Editor UI is frozen for its duration. time_budget_ms is the cooperative bound on that /// freeze: the elapsed time is checked before each operation after the first, and when the budget /// is exhausted the remaining ops are skipped and a transactional batch rolls back. Long batches /// should be submitted as detached jobs ("job": true) — cancellation is checked between /// ops, and per-op progress is reported via . /// #if UNITY_6000_5_OR_NEWER [NoAutoStaticsCleanup] #endif static class BatchCommand { /// Hard cap on operations per batch (payload bound). private const int MaxOperations = 200; /// Default cooperative time budget for one batch execution (see ). private const int DefaultTimeBudgetMs = 50_000; /// Upper bound on time_budget_ms (1 hour). private const int MaxTimeBudgetMs = 3_600_000; /// /// Per-op result size (serialized UTF-8 BYTES — what actually goes on the wire, not UTF-16 /// chars, which undercount CJK/emoji-heavy payloads 3x) above which the result is replaced /// by a truncation marker so a large sub-result cannot bloat the batch envelope. /// private const int MaxPerOpResultBytes = 16 * 1024; /// /// Aggregate budget for ALL per-op results in one batch response (UTF-8 bytes). Bounds the /// whole envelope: 200 ops each just under the per-op cap would otherwise produce a ~3.2 MB /// reply. Once exhausted, later results are replaced by truncation markers (the ops still /// execute normally — only the echoed result payload is elided). /// internal const int MaxBatchResultBytes = 256 * 1024; /// /// Operation ids must look like identifiers: they may not be purely numeric (a numeric id /// would be ambiguous with a 0-based index in a "$<id-or-index>" reference) and /// may not contain '.' (the reference path separator). /// private static readonly Regex m_IdPattern = new Regex("^[A-Za-z_][A-Za-z0-9_-]*$", RegexOptions.Compiled); /// /// Commands excluded from batch (structured not_batchable error). These either escape /// the "object CRUD with references" scope (build/platform/package/play-mode/recompile), carry /// arbitrary code that belongs in run_script (eval/eval_file/run_script), /// can pop modal dialogs that wedge an unattended batch (menu), or would nest a batch /// inside a batch. (The ticket names eval; eval_file is its file-based twin and /// is excluded on the same grounds.) /// private static readonly HashSet m_ExcludedCommands = new HashSet(StringComparer.Ordinal) { "build", "switch_build_target", "editor_play", "editor_stop", "editor_pause", "recompile", "eval", "eval_file", "run_script", "menu", "batch", // A synchronous wait inside a batch cannot make progress: the batch holds the main // thread for its whole turn, so the editor state the condition watches cannot change — // the wait just burns its full timeout inside the batch. Use an async wait_for before // or after the batch instead. (wait_status/wait_cancel are quick reads and stay // batchable.) "wait_for" }; /// Excluded command families matched by name prefix (e.g. all package_*). private static readonly string[] m_ExcludedPrefixes = { "package_" }; /// /// Commands that CLEAR the Editor Undo stack, destroying the batch's ability to revert any /// prior operation. Always rejected inside a batch (even with transactional=false the /// single-Undo-step contract would silently break). /// /// V1 CAVEAT: like below, this is a curated list — /// any new command that clears or truncates the Undo stack must be added here, or it will /// silently break the revert-integrity contract. The durable fix is command-site metadata /// ([CliCommand(Revertible = ...)]). /// private static readonly HashSet m_UndoStackWipingCommands = new HashSet(StringComparer.Ordinal) { "open_scene", "create_scene" }; /// /// Commands whose mutations live OUTSIDE the Editor Undo system — asset/file writes /// (AssetDatabase / File IO), scene saves, Build Settings, project settings — so /// cannot roll them back. In a transactional /// batch they are rejected (not_batchable_transactional): allowing them would make /// reverted: true a lie. With transactional=false they run, and their per-op /// result reports revertible: false. /// /// V1 CAVEAT: this is a curated list, assembled by auditing each command's /// implementation (AssetDatabase.SaveAssets / File writes / settings assets). The durable fix /// is a [CliCommand(Revertible = ...)] metadata flag declared at the command site so /// new commands can't silently fall through; until then, new non-Undo-tracked commands must /// be added here. /// private static readonly HashSet m_NonRevertibleCommands = new HashSet(StringComparer.Ordinal) { // Assets & files (AssetDatabase ops / direct file IO). "create_asset", "import_asset", "move_asset", "copy_asset", "rename_asset", "delete_asset", "create_folder", "set_import_settings", "write_text_file", "create_script", // Prefab ASSET writes (scene-side prefab ops — instantiate/unpack/revert overrides — are // Undo-tracked and stay batchable). "create_prefab", "create_prefab_variant", "apply_prefab_overrides", "save_prefab_contents", // Scene persistence and Build Settings scene list. "save_scene", "save_all", "add_scene_to_build", "remove_scene_from_build", "set_build_settings", // Project settings (their own descriptions say "Not undoable via Ctrl+Z"). "set_audio_settings", "set_graphics_settings", "set_input_settings", "set_lighting_settings", "set_navmesh_settings", "set_physics_settings", "set_player_settings", "set_quality_settings", "set_tags_layers", "set_time_settings", "set_authoring_root", // Animation / Animator / Timeline commands persist their asset with AssetDatabase.SaveAssets. "create_animation_clip", "set_animation_curve", "remove_animation_curve", "create_animator_controller", "add_animator_layer", "add_animator_parameter", "add_animator_state", "add_animator_transition", "create_timeline", "add_timeline_track", "add_timeline_clip", // Persists the .mat asset with AssetDatabase.SaveAssetIfDirty (the in-memory change is // Undo-recorded, but the disk write is not reverted by Undo). "set_material_properties", // Baking writes/deletes baked data on disk (GI / NavMesh / occlusion). "bake_lighting", "clear_baked_lighting", "bake_navmesh", "bake_navmesh_surfaces", "clear_navmesh", "bake_occlusion_culling", "clear_occlusion_culling" }; [CliCommand("batch", "Run multiple registered commands in one transactional request. Later ops can reference earlier " + "results with \"$.\" (e.g. \"$0.instanceId\"; \"$$\" escapes a literal '$'). " + "transactional=true (default) groups every op into one Undo step and reverts all applied ops if any " + "op fails; commands that mutate outside the Undo system (asset/file/settings writes) are rejected " + "unless transactional=false. on_error=abort|continue (continue forces transactional=false); dry_run " + "validates without mutating; time_budget_ms (default 50000) bounds execution — when exceeded the " + "remaining ops are skipped and a transactional batch rolls back (submit long batches with " + "\"job\": true). Excludes build, switch_build_target, package_*, editor_play/stop/pause, recompile, " + "eval, eval_file, run_script, menu, open_scene/create_scene, async commands (run_tests/list_tests), " + "runtime-only commands, and nested batch.", MainThreadRequired = true, Tags = new[] { "batch" })] public static BatchResult Batch( [CliArg("operations", "Ordered operations to run; each is { id?, command, params }. Max 200.", Required = true)] List operations = null, [CliArg("transactional", "Group all ops into one Undo step and revert every applied op if any op fails. Default true. Forced false when on_error=continue. Rejects commands whose mutations Undo cannot revert (asset/file/settings writes).")] bool transactional = true, [CliArg("on_error", "abort (default): stop at the first failing op. continue: run every op, collecting per-op errors (forces transactional=false).")] string onError = "abort", [CliArg("dry_run", "Validate command names, parameters, reference topology and excluded commands without mutating anything.")] bool dryRun = false, [CliArg("result_fields", "Optional per-op result projection: map of op id-or-index -> array of result field paths to keep (context economy).")] JObject resultFields = null, [CliArg("time_budget_ms", "Cooperative time budget for the whole batch in milliseconds (default 50000, max 3600000). Checked before each op after the first; when exhausted the remaining ops are skipped and a transactional batch rolls back. Use \"job\": true for long batches.")] int timeBudgetMs = DefaultTimeBudgetMs) { if (operations == null || operations.Count == 0) throw new ArgumentException("batch 'operations' must contain at least one operation."); if (operations.Count > MaxOperations) throw new ArgumentException( $"batch supports at most {MaxOperations} operations, but {operations.Count} were provided."); if (timeBudgetMs < 0 || timeBudgetMs > MaxTimeBudgetMs) throw new ArgumentException( $"batch 'time_budget_ms' must be between 0 and {MaxTimeBudgetMs}, got {timeBudgetMs}."); onError = string.IsNullOrWhiteSpace(onError) ? "abort" : onError.Trim().ToLowerInvariant(); if (onError != "abort" && onError != "continue") throw new ArgumentException($"batch 'on_error' must be 'abort' or 'continue', got '{onError}'."); // "continue" collects per-op errors and applies the independent ops, so it cannot also // promise an all-or-nothing rollback — it forces transactional off (per the ticket). var effectiveTransactional = transactional && onError != "continue"; var idToIndex = BuildAndValidateIdIndex(operations); if (dryRun) return DryRun(operations, idToIndex, effectiveTransactional, onError); return Execute(operations, idToIndex, effectiveTransactional, onError, resultFields, timeBudgetMs); } /// /// Validate operation ids (well-formed and unique when supplied) and build the id → index map /// used to resolve "$id.field" references. /// private static Dictionary BuildAndValidateIdIndex(List operations) { var idToIndex = new Dictionary(StringComparer.Ordinal); for (int i = 0; i < operations.Count; i++) { var op = operations[i]; if (op == null) throw new ArgumentException($"batch operation at index {i} is null."); if (op.Id == null) continue; if (!m_IdPattern.IsMatch(op.Id)) throw new ArgumentException( $"batch operation id '{op.Id}' (index {i}) is invalid: ids must match " + "^[A-Za-z_][A-Za-z0-9_-]*$ (start with a letter or '_'; letters, digits, '_' or '-' " + "after). Purely numeric ids would be ambiguous with 0-based index selectors in " + "\"$\" references, and '.' collides with the reference path separator."); if (idToIndex.ContainsKey(op.Id)) throw new ArgumentException( $"batch operation id '{op.Id}' is used more than once; ids must be unique within a batch."); idToIndex[op.Id] = i; } return idToIndex; } /// /// Execute the batch: run each op (resolving its references against earlier results) inside one /// Undo group, and on a transactional abort revert every applied op. Between ops, cooperative /// cancellation and the time budget are checked, and per-op progress is reported. /// private static BatchResult Execute(List operations, Dictionary idToIndex, bool transactional, string onError, JObject resultFields, int timeBudgetMs) { var server = BasePipelineServer.CurrentServer; if (server == null) throw new InvalidOperationException( "batch must run inside a pipeline server request (no active server on this thread)."); var result = new BatchResult { Transactional = transactional, OnError = onError }; // One name-indexed lookup per batch call instead of an O(commands) scan per op. var commandsByName = BuildCommandIndex(); var resultsByIndex = new JToken[operations.Count]; var stoppedAt = -1; // index of the op that failed under on_error=abort var skippedFrom = -1; // first op never attempted because of a batch-level abort (budget/cancel) var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var remainingResultBytes = MaxBatchResultBytes; using (var undoScope = new AuthoringUndoScope("batch")) { var batchGroup = Undo.GetCurrentGroup(); result.UndoGroup = batchGroup; for (int i = 0; i < operations.Count; i++) { // Cooperative cancellation (detached jobs: POST /api/job/cancel) — abort between // ops and let the transactional rollback below restore the pre-batch state. if (PipelineCancellation.IsCancellationRequested) { result.Error = "batch canceled: cooperative cancellation was requested; " + (transactional ? "applied operations were reverted and " : "") + "remaining operations were skipped."; skippedFrom = i; break; } // Cooperative time budget: the batch holds the main thread for its whole duration, // so this is the bound on how long the Editor stays frozen. Op 0 always gets to // run; every later op requires remaining budget. if (i > 0 && stopwatch.ElapsedMilliseconds >= timeBudgetMs) { result.Error = $"batch time budget exceeded: {stopwatch.ElapsedMilliseconds}ms elapsed " + $"after {i} of {operations.Count} operations (time_budget_ms={timeBudgetMs}). " + (transactional ? "Applied operations were reverted and remaining operations were skipped. " : "Remaining operations were skipped. ") + "For long batches submit the request with \"job\": true and a larger time_budget_ms."; skippedFrom = i; break; } var op = operations[i]; CliProgress.Report("batch", $"op {i + 1}/{operations.Count}: {op.Command}", i, operations.Count, i / (double)operations.Count); var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command }; result.Results.Add(opResult); try { var command = ResolveBatchable(op.Command, transactional, commandsByName); opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command); var resolvedParams = BatchReferenceResolver.Resolve( op.Params, i, operations, idToIndex, resultsByIndex, result.Results); // Dispatched WITHOUT re-checking the server's busy gate (GetBusyReason): // the batch itself was admitted through it in HandleExecRequest, the settle // gate is a one-way latch that never re-arms mid-session, and the batch // holds the main thread for its entire turn — the Editor cannot transition // into a busy state between two ops of the same batch. var raw = server.DispatchCommandOnMainThread(command, resolvedParams); // Capture the result as a token for later references. A serialization failure // here must NOT fail an op that already ran — leave the slot null (a later // reference into it will error clearly) and still report success. try { resultsByIndex[i] = raw == null ? JValue.CreateNull() : JToken.FromObject(raw); } catch { resultsByIndex[i] = null; } opResult.Success = true; opResult.Result = ProjectResult(raw, resultsByIndex[i], op, i, resultFields, opResult, ref remainingResultBytes); result.Applied++; } catch (Exception ex) { opResult.Success = false; opResult.Error = ex.Message; if (onError == "abort") { stoppedAt = i; break; } // on_error=continue: keep going with the remaining independent ops. } } if ((stoppedAt >= 0 || skippedFrom >= 0) && transactional) { // Revert every mutation registered since the batch started — one atomic rollback // to the pre-batch state (nothing is left on the Undo stack). Deliberately NOT // conditioned on Applied > 0: a FIRST op that partially mutates and then throws // has registered exactly the Undo operations this revert must unwind, and // reverting an empty group is harmless. Undo.RevertAllDownToGroup(batchGroup); result.Reverted = true; // The revert discarded batchGroup from the Undo stack; the scope must not // collapse a group id that no longer refers to this batch's operations. undoScope.Cancel(); } } // Record the operations that were never attempted because the batch aborted (op failure // under on_error=abort, cancellation, or an exhausted time budget), so the caller can see // exactly where it stopped. var firstSkipped = skippedFrom >= 0 ? skippedFrom : stoppedAt >= 0 ? stoppedAt + 1 : operations.Count; for (int j = firstSkipped; j < operations.Count; j++) { result.Results.Add(new BatchOperationResult { Id = operations[j].Id, Command = operations[j].Command, Success = false, Skipped = true }); } return result; } /// /// Validate every operation without executing any of it: command names, that no excluded / /// non-batchable command is used (including the transactional non-revertible rejection), /// unknown/missing parameters, and reference topology (unknown/forward refs). Mutates nothing. /// private static BatchResult DryRun(List operations, Dictionary idToIndex, bool transactional, string onError) { var result = new BatchResult { DryRun = true, Valid = true, Transactional = transactional, OnError = onError }; var commandsByName = BuildCommandIndex(); for (int i = 0; i < operations.Count; i++) { var op = operations[i]; var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command }; result.Results.Add(opResult); try { var command = ResolveBatchable(op.Command, transactional, commandsByName); opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command); ValidateKnownParameters(command, op.Params); ValidateRequiredParameters(command, op.Params); BatchReferenceResolver.Validate(op.Params, i, operations, idToIndex); opResult.Success = true; // would run } catch (Exception ex) { opResult.Success = false; opResult.Error = ex.Message; result.Valid = false; } } return result; } /// /// Resolve a command name to its , rejecting non-batchable commands: /// the excluded list (not_batchable), Undo-stack-wiping scene loads, runtime-only /// commands, async (Task-returning) commands — which are completed by /// EditorApplication.update callbacks that can never fire while the batch holds the /// main thread, so awaiting them inline would freeze the Editor permanently — and, when /// , commands whose mutations Undo cannot revert /// (not_batchable_transactional). Unknown commands get an unknown command error. /// Uses the registry directly (no server-side logging) so dry-run validation of an /// intentionally bad name stays quiet. /// /// /// Name-indexed snapshot of the registered commands, built once per batch call (the registry /// list itself is cached; this avoids an O(commands) scan per op). NOTE: this deliberately /// does not reuse BasePipelineServer.ResolveCommand — that helper serves the exec /// request path (logging, envelope error shape); unifying the two belongs to a follow-up so /// the server's hot path is not reshaped from this command. /// private static Dictionary BuildCommandIndex() { var byName = new Dictionary(StringComparer.Ordinal); foreach (var c in CommandRegistry.DiscoverCommands()) if (!byName.ContainsKey(c.Name)) byName.Add(c.Name, c); return byName; } private static CommandInfo ResolveBatchable(string name, bool transactional, IReadOnlyDictionary byName) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("operation 'command' is required."); if (IsExcluded(name)) throw new ArgumentException( $"Command '{name}' is not batchable (not_batchable): commands that build, switch platform, " + "manage packages, control play mode, recompile, evaluate code, execute Editor menu items " + "(which can open modal dialogs and wedge an unattended batch), or nest a batch are excluded."); if (m_UndoStackWipingCommands.Contains(name)) throw new ArgumentException( $"Command '{name}' is not batchable (not_batchable): it clears the Editor Undo stack, which " + "would make every prior batch operation unrevertible. Call it standalone instead."); if (!byName.TryGetValue(name, out var command)) throw new ArgumentException($"No command named '{name}' is available (unknown command)."); if (command.RuntimeOnly) throw new ArgumentException( $"Command '{name}' is not batchable (not_batchable): runtime-only command (Player server " + "surface); it cannot execute on this Editor server."); if (typeof(Task).IsAssignableFrom(command.Method.ReturnType)) throw new ArgumentException( $"Command '{name}' is not batchable (not_batchable): async commands cannot run inside a " + "synchronous batch. They complete via EditorApplication.update callbacks, which cannot fire " + "while the batch holds the main thread — the batch would deadlock the Editor. Call it " + "standalone instead."); if (transactional && m_NonRevertibleCommands.Contains(name)) throw new ArgumentException( $"Command '{name}' is not batchable in a transactional batch (not_batchable_transactional): " + "it mutates state outside the Editor Undo system (asset/file/settings writes), so a rollback " + "could not revert it. Run the batch with transactional:false, or call the command standalone."); return command; } private static bool IsExcluded(string name) { if (m_ExcludedCommands.Contains(name)) return true; foreach (var prefix in m_ExcludedPrefixes) { if (name.StartsWith(prefix, StringComparison.Ordinal)) return true; } return false; } /// Reject parameter keys that are not parameters of the command (dry-run check). private static void ValidateKnownParameters(CommandInfo command, JObject parameters) { if (parameters == null) return; var known = new HashSet(command.Parameters.Select(p => p.Name), StringComparer.Ordinal); foreach (var prop in parameters.Properties()) { if (!known.Contains(prop.Name)) throw new ArgumentException( $"Command '{command.Name}' has no parameter '{prop.Name}' (unknown parameter). " + $"Known parameters: [{string.Join(", ", known)}]."); } } /// /// Ensure every required parameter is present (dry-run check). A reference string counts as /// present; an explicit JSON null does not. /// private static void ValidateRequiredParameters(CommandInfo command, JObject parameters) { foreach (var p in command.Parameters) { if (!p.Required) continue; var token = parameters?[p.Name]; if (token == null || token.Type == JTokenType.Null) throw new ArgumentException( $"Command '{command.Name}' is missing required parameter '{p.Name}'."); } } /// /// Shape an op's result: apply the optional result_fields projection, then replace an /// oversized result with a truncation marker. With neither in play the raw result object is /// returned unchanged, so it serializes byte-identically to the standalone command. /// internal static object ProjectResult(object raw, JToken rawToken, BatchOperationInput op, int index, JObject resultFields, BatchOperationResult opResult, ref int remainingResultBytes) { object projected = raw; var fields = LookupResultFields(resultFields, op, index); if (fields != null && rawToken != null && rawToken.Type != JTokenType.Null) { var projection = new JObject(); List invalidPaths = null; foreach (var path in fields) { if (string.IsNullOrEmpty(path)) continue; JToken value; try { value = rawToken.SelectToken(path); } catch (Exception) { // A malformed JSONPath must NEVER throw here: by this point the op has // already run and mutated state, and the outer catch would flip its // Success to false — triggering a rollback of a succeeded op under // on_error=abort, and poisoning later $refs into a perfectly valid // captured result under on_error=continue. Report the bad path in the // projection instead. (invalidPaths ?? (invalidPaths = new List())).Add(path); continue; } if (value != null) projection[path] = value; } if (invalidPaths != null) projection["invalidResultFields"] = new JArray(invalidPaths); projected = projection; } string json; try { // Measure with the same formatting the wire envelope uses: since the lean envelope // (AUTHAPI-21) /api/exec serializes compact (Formatting.None) in every mode, so the // measurement matches what actually goes on the wire. json = JsonConvert.SerializeObject(projected, Formatting.None); } catch (Exception ex) { // A result Newtonsoft cannot serialize would fail (or bloat) the whole batch envelope // later. Replace it with a structured marker — and still flag resultTruncated — rather // than skipping truncation for exactly the results we know nothing about. opResult.ResultTruncated = true; return new JObject { ["resultTruncated"] = true, ["error"] = $"op result could not be serialized: {ex.Message}" }; } var bytes = System.Text.Encoding.UTF8.GetByteCount(json); if (bytes > MaxPerOpResultBytes) { opResult.ResultTruncated = true; return new JObject { ["resultTruncated"] = true, ["length"] = bytes, ["preview"] = json.Substring(0, Math.Min(json.Length, 512)) }; } if (bytes > remainingResultBytes) { // Aggregate budget exhausted: the op executed normally, only its echoed result is // elided so the whole batch reply stays bounded ($refs still resolve — they read // the captured token, not this projection). opResult.ResultTruncated = true; return new JObject { ["resultTruncated"] = true, ["length"] = bytes, ["error"] = $"batch aggregate result budget ({MaxBatchResultBytes} bytes) exhausted; " + "the operation ran and later references to it still resolve, only this echoed result was elided. " + "Use result_fields to project results down, or split the batch.", ["preview"] = json.Substring(0, Math.Min(json.Length, 512)) }; } remainingResultBytes -= bytes; return projected; } /// /// Look up the projection field list for an op in result_fields (keyed by op id, then /// by index). Returns null when no projection is configured for the op. /// private static string[] LookupResultFields(JObject resultFields, BatchOperationInput op, int index) { if (resultFields == null) return null; JToken entry = null; if (!string.IsNullOrEmpty(op.Id)) entry = resultFields[op.Id]; if (entry == null) entry = resultFields[index.ToString()]; if (entry == null) return null; if (entry.Type == JTokenType.Array) return entry.ToObject(); if (entry.Type == JTokenType.String) return new[] { (string)entry }; return null; } } }