Files
AIGame/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitConditionEvaluator.cs
T

787 lines
37 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json.Linq;
using Unity.Pipeline;
using Unity.Pipeline.Editor.Authoring;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands
{
/// <summary>
/// Thrown when a wait condition cannot be resolved or evaluated (bad member path, unresolved
/// target, unreadable member, un-coercible operand). Distinct from a timeout: the wait reports
/// these as a <c>failed</c> result with this message, never as a spurious <c>timedOut</c>
/// (unless <c>tolerate_missing</c> is set, in which case resolution failures are retried as
/// condition-not-met until the wait's own budget expires) — UNLESS <see cref="IsPermanent"/> is
/// set, for a failure no amount of retrying can ever resolve (a malformed member path, a
/// findType naming a non-<see cref="UnityEngine.Object"/> type, a member that does not exist on
/// the resolved type): those fail fast even under tolerate_missing, whose contract is "does not
/// exist YET", not "will never exist".
/// </summary>
internal sealed class WaitEvaluationException : Exception
{
internal bool IsPermanent { get; }
public WaitEvaluationException(string message, bool isPermanent = false) : base(message)
{
IsPermanent = isPermanent;
}
}
/// <summary>
/// Cached resolution state for one wait's condition. The expensive part of member resolution —
/// <see cref="WaitConditionEvaluator.ResolveType"/>'s whole-appdomain type scan — is cached here
/// instead of running once per poll: a successful scan never repeats, and a failed one is only
/// retried after the loaded-assembly set changes (relevant to <c>tolerate_missing</c> waits on
/// not-yet-loadable paths). A <c>findType</c> instance binds once and re-scans
/// only when the bound instance is destroyed; an explicit <c>target</c> handle is re-resolved
/// every poll, because it can legitimately change mid-wait. Member lookups are
/// memoized per encountered runtime type, so steady-state polls perform dictionary hits, not
/// reflection walks.
///
/// Created when the wait is registered (pure reflection state — safe off the main thread);
/// afterwards it is only touched by <see cref="WaitJob.PollOnce"/> on the main thread.
/// </summary>
internal sealed class WaitConditionPlan
{
internal readonly WaitConditionInput Condition;
internal readonly string[] Segments;
/// <summary>Resolved type of a static member path's leading prefix (cached after the first successful scan).</summary>
internal Type StaticType;
/// <summary>Number of leading segments consumed by <see cref="StaticType"/>.</summary>
internal int StaticSplit;
/// <summary>Resolved <c>findType</c> (cached after the first successful scan).</summary>
internal Type FindTypeResolved;
/// <summary>The live instance a <c>findType</c> wait is bound to; re-resolved only when it
/// has been destroyed (never silently re-bound while alive).</summary>
internal Object BoundInstance;
/// <summary>The ambiguity note captured when <see cref="BoundInstance"/> was bound,
/// re-surfaced on every poll for the wait result.</summary>
internal string BoundNote;
/// <summary>Ambiguity note from type resolution (a bare type name matching several loaded
/// types), captured once when the scan resolves and re-surfaced on every poll.</summary>
internal string TypeNote;
/// <summary>
/// Signature of the loaded-assembly SET observed by the last FAILED type-resolution scan
/// (combines identity hashes, not just <c>Count</c> — an unload-and-load-of-a-different-
/// assembly can leave the count unchanged while still making a previously-unresolvable name
/// resolvable). A failed scan is only re-attempted once this changes: within a wait's
/// lifetime (waits don't survive domain reloads) the only way an unresolvable name becomes
/// resolvable is a newly loaded assembly (e.g. <c>run_script</c>/<c>eval</c> loading one).
/// Without this, a <c>tolerate_missing</c> wait on a never-resolving path would repeat the
/// whole-appdomain scan every poll. Null means "never scanned" — distinct from any signature
/// value, including a coincidental 0 — so the very first call always rescans.
/// </summary>
internal long? FailedScanAssemblySignature;
/// <summary>Non-fatal note from the latest resolution (e.g. an ambiguous findType match), surfaced on the wait result.</summary>
internal string ResolutionNote;
private readonly Dictionary<(Type Lookup, int Segment, bool IsStatic), MemberInfo> m_Members =
new Dictionary<(Type, int, bool), MemberInfo>();
internal WaitConditionPlan(WaitConditionInput condition)
{
Condition = condition;
Segments = (condition?.Member ?? string.Empty).Trim().Split('.');
}
internal bool TryGetCachedMember(Type lookup, int segment, bool isStatic, out MemberInfo member)
=> m_Members.TryGetValue((lookup, segment, isStatic), out member);
internal void CacheMember(Type lookup, int segment, bool isStatic, MemberInfo member)
=> m_Members[(lookup, segment, isStatic)] = member;
}
/// <summary>
/// Reads and compares a member value for the <c>wait_for</c> command (AUTHAPI-25).
///
/// Member resolution mirrors the rules AUTHAPI-16's invoke/query surface will use (that shared
/// resolver does not exist yet, so this is a self-contained implementation the two can later
/// converge on — see PR notes):
/// - <c>target</c> (an <see cref="Unity.Pipeline.Models.ObjectRef"/>) resolves via
/// <see cref="ObjectResolver"/>; the member path then walks instance members from it.
/// - <c>findType</c> resolves a UnityEngine.Object type and walks from a live (non-persistent,
/// unhidden) instance — prefab assets and hidden editor internals are filtered out, scene
/// instances are preferred, and an ambiguous match binds deterministically to the lowest
/// instance id with a note on the result.
/// - Otherwise the member path is a fully-qualified static path (<c>Ns.Type.StaticMember.Nested</c>).
///
/// Only readable fields and properties are resolved — write-only members and methods are rejected,
/// so the surface is read-only by construction.
/// </summary>
internal static class WaitConditionEvaluator
{
// PUBLIC members only, deliberately: wait conditions observe the same surface any C#
// consumer of the API sees. Reading private/internal state by name would let the `wait`
// tag expose internals below eval's capability gate (a host can disable eval but allow
// wait) — anyone who genuinely needs private state has eval/run_script on this server.
private const BindingFlags MemberFlags = BindingFlags.Public;
/// <summary>Convenience overload for one-shot reads (tests): builds a throwaway plan.</summary>
internal static object ReadMember(WaitConditionInput condition, out Type memberType)
=> ReadMember(new WaitConditionPlan(condition), out memberType);
/// <summary>
/// Resolve the plan's member path and return the current value plus its declared type (used
/// to coerce the comparison operand). Type resolution results are cached on
/// <paramref name="plan"/> so repeated polls never repeat the appdomain scan; only the live
/// instance (target / findType) is re-resolved per call. Throws
/// <see cref="WaitEvaluationException"/> on any resolution failure.
/// </summary>
internal static object ReadMember(WaitConditionPlan plan, out Type memberType)
{
memberType = typeof(object);
if (plan == null || plan.Condition == null)
throw new WaitEvaluationException("condition is required.", isPermanent: true);
plan.ResolutionNote = null;
var condition = plan.Condition;
var path = (condition.Member ?? string.Empty).Trim();
if (path.Length == 0)
throw new WaitEvaluationException("condition.member is required.", isPermanent: true);
var segments = plan.Segments;
object current;
Type staticType = null;
int start;
var isFindTypeInstance = false;
if (condition.Target != null && !condition.Target.IsEmpty)
{
// A malformed globalId is a property of the input STRING itself — no amount of
// retrying ever makes it parseable, unlike every other TryResolve failure below
// (empty/unknown/not-yet-loaded), which can legitimately resolve once the
// referenced object appears — the same "wait for a spawned object" story `target`
// shares with `findType`. Checked locally rather than widening ObjectResolver's
// public TryResolve (20+ call sites) with a permanence flag it has no other use for.
if (!string.IsNullOrEmpty(condition.Target.GlobalId) &&
!GlobalObjectId.TryParse(condition.Target.GlobalId, out _))
throw new WaitEvaluationException(
$"Invalid condition.target.globalId '{condition.Target.GlobalId}'.", isPermanent: true);
// Re-resolved every poll on purpose: the referenced object can appear/change mid-wait.
if (!ObjectResolver.TryResolve(condition.Target, out var obj, out var err))
throw new WaitEvaluationException($"Could not resolve condition.target: {err}");
current = obj;
start = 0;
}
else if (!string.IsNullOrEmpty(condition.FindType))
{
// The type scan is cached (failures too, until a new assembly loads); the instance
// lookup stays per-poll (it can legitimately change).
var type = plan.FindTypeResolved;
if (type == null && ShouldRescan(plan))
{
type = plan.FindTypeResolved = ResolveType(condition.FindType, out var typeNote);
if (type != null)
plan.TypeNote = typeNote;
}
if (type == null)
throw new WaitEvaluationException($"Unknown findType '{condition.FindType}'.");
if (plan.BoundInstance is Object boundInstance && boundInstance)
{
// Bound once, reused until destroyed: re-scanning every loaded object on every
// poll (up to ~60/s at the minimum interval) scales with scene size for no
// benefit — the bind is deterministic anyway. Destruction of the bound
// instance triggers a re-scan, so the spawn-wait and replaced-after-destroy
// stories keep working.
current = boundInstance;
}
else
{
current = FindInstance(type, out var note)
?? throw new WaitEvaluationException($"No live instance of type '{type.FullName}' (persistent assets and hidden objects are excluded).");
plan.BoundInstance = (Object)current;
plan.BoundNote = note;
}
plan.ResolutionNote = plan.BoundNote;
start = 0;
isFindTypeInstance = true;
}
else
{
// Static path: find the longest leading dotted prefix that resolves to a type,
// leaving at least one trailing member segment. Resolved once, then cached.
if (segments.Length < 2)
// A property of the input STRING, not of anything that could load later —
// permanent regardless of tolerate_missing.
throw new WaitEvaluationException(
$"Static member path '{path}' must be 'Type.Member'; supply 'target' or 'findType' for instance members.",
isPermanent: true);
if (plan.StaticType == null && ShouldRescan(plan))
{
for (var k = segments.Length - 1; k >= 1; k--)
{
var prefix = string.Join(".", segments, 0, k);
var t = ResolveType(prefix, out var typeNote);
if (t != null)
{
plan.StaticType = t;
plan.StaticSplit = k;
plan.TypeNote = typeNote;
break;
}
}
}
if (plan.StaticType == null)
throw new WaitEvaluationException(
$"Could not resolve a type from static path '{path}'. Supply 'target' or 'findType' for instance members.");
staticType = plan.StaticType;
current = null;
start = plan.StaticSplit;
}
object value = current;
var firstStatic = current == null;
for (var i = start; i < segments.Length; i++)
{
var segment = segments[i];
var isStatic = value == null && firstStatic;
if (value == null && !firstStatic)
throw new WaitEvaluationException($"Member path '{path}' hit null before segment '{segment}'.");
// An object-typed `== null` misses UnityEngine.Object's fake-null: a destroyed
// object would pass and the next getter would throw a raw
// MissingReferenceException instead of a structured evaluation failure.
if (value is Object destroyedCheck && !destroyedCheck)
throw new WaitEvaluationException(
$"Member path '{path}' hit a destroyed Unity object before segment '{segment}'.");
var lookup = value?.GetType() ?? staticType;
if (lookup == null)
throw new WaitEvaluationException($"Could not determine a type to resolve segment '{segment}'.", isPermanent: true);
// TryReadMember's failures (no such readable member, write-only, indexer) are
// ordinarily properties of the resolved TYPE's shape, which cannot change without
// a domain reload — and a wait does not survive one. Permanent: never worth
// retrying. EXCEPT the very first segment off a findType-bound instance: which
// concrete subtype got bound is a deterministic-but-arbitrary tie-break among
// whichever live candidates exist right now (FindInstance's lowest-instance-id
// pick), not a fixed property of the wait's target the way `target`/static paths
// are — a sibling instance of a different subtype (already alive, or not yet
// spawned) might have this member even though the one we happened to bind doesn't.
// Failing fast here would defeat tolerate_missing's "wait for the right kind of
// spawned object" story; retrying until timeout is the honest answer instead.
var ambiguousCandidate = isFindTypeInstance && i == start;
if (!TryReadMember(plan, i, lookup, segment, isStatic, value, out var next, out var nextType, out var readError))
throw new WaitEvaluationException(readError, isPermanent: !ambiguousCandidate);
value = next;
memberType = nextType;
firstStatic = false;
}
if (plan.TypeNote != null)
plan.ResolutionNote = plan.ResolutionNote == null
? plan.TypeNote
: plan.TypeNote + " " + plan.ResolutionNote;
// A destroyed Unity object as the TERMINAL value is observed as null — Unity's own
// == semantics — so a condition can express "wait until destroyed" as
// { op: "equals", value: null }. (Traversal THROUGH a destroyed object still throws
// the structured error above; only the leaf value normalizes.)
if (value is Object terminalUnityObject && !terminalUnityObject)
value = null;
if (memberType == typeof(object) && value != null)
memberType = value.GetType();
return value;
}
/// <summary>
/// Evaluate an operator against a freshly read <paramref name="current"/> value. For
/// <c>changed</c>, compares against <paramref name="initialValue"/> (the first observed value)
/// and never needs an operand. All other operators coerce the JSON operand to
/// <paramref name="memberType"/> first. Sets <paramref name="error"/> (and returns false) when
/// the operator or operand cannot be applied.
/// </summary>
internal static bool Evaluate(string op, object current, object operandRaw, Type memberType,
object initialValue, bool haveInitial, out string error)
{
error = null;
// Mirrors WaitForCommands.ValidateCondition's normalization exactly: an empty string is
// as much "not specified" as a null, and the two must agree, or a condition that passed
// validation as a legal equals-wait (op:"") would fail every poll on "Unknown op ''".
op = string.IsNullOrEmpty(op) ? "equals" : op.Trim();
if (op == "changed")
return haveInitial && !ValuesEqual(current, initialValue);
var operandIsNull = operandRaw == null
|| (operandRaw is JToken nullToken && nullToken.Type == JTokenType.Null);
if (operandIsNull && (op == "equals" || op == "notEquals"))
{
// A null operand is meaningful for (in)equality — including "wait until
// destroyed", since ReadMember observes a destroyed leaf as null. Bypass coercion
// (there is no type to coerce null to) and compare null-ness directly,
// fake-null-aware for direct evaluator calls.
var isNull = current == null || (current is Object unityCurrent && !unityCurrent);
return op == "equals" ? isNull : !isNull;
}
if (!TryCoerceOperand(operandRaw, memberType, out var operand, out error))
return false;
switch (op)
{
case "equals":
return ValuesEqual(current, operand);
case "notEquals":
return !ValuesEqual(current, operand);
case "greaterThan":
case "lessThan":
if (!TryCompareNumeric(current, operand, out var comparison, out error))
return false;
return op == "greaterThan" ? comparison > 0 : comparison < 0;
case "contains":
if (!(current is string s))
{
error = "op 'contains' requires a string member.";
return false;
}
var sub = operand as string ?? operand?.ToString() ?? string.Empty;
return s.IndexOf(sub, StringComparison.Ordinal) >= 0;
default:
error = $"Unknown op '{op}'.";
return false;
}
}
/// <summary>
/// Resolve a type by assembly-qualified name, full name, or simple name across loaded
/// assemblies. This is the expensive whole-appdomain scan — callers cache the result on the
/// wait's <see cref="WaitConditionPlan"/> so it runs at most once per wait, not per poll.
/// </summary>
internal static Type ResolveType(string name) => ResolveType(name, out _);
internal static Type ResolveType(string name, out string ambiguityNote)
{
ambiguityNote = null;
if (string.IsNullOrEmpty(name))
return null;
var direct = Type.GetType(name, throwOnError: false);
if (direct != null)
return direct;
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
{
var t = asm.GetType(name, throwOnError: false);
if (t != null)
return t;
}
// A dotted name can never equal a simple Type.Name (which never contains '.') — skip
// the whole-appdomain scan below for it. This matters: ReadMember's static-path prefix
// loop calls ResolveType once per FAILING dotted prefix (e.g. "Ns.Type.Nested", "Ns.Type"
// for a 4-segment path), and without this guard every one of those burns a full scan that
// can never succeed.
if (name.IndexOf('.') >= 0)
return null;
// Simple-name fallback: a bare name can match several loaded types (e.g. "Debug" in
// UnityEngine and System.Diagnostics). Bind DETERMINISTICALLY (lowest full name
// ordinally) instead of assembly-enumeration order, and surface the ambiguity as a
// note so the caller learns to fully qualify.
Type chosen = null;
List<string> matches = null;
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
{
foreach (var candidate in SafeGetTypes(asm))
{
// No FullName check here: the asm.GetType(name) pass above already covered
// every exact-full-name match, so by this point only simple names can hit.
if (candidate.Name != name)
continue;
if (chosen == null || string.CompareOrdinal(candidate.FullName, chosen.FullName) < 0)
chosen = candidate;
(matches ?? (matches = new List<string>())).Add(candidate.FullName);
}
}
if (matches != null && matches.Count > 1)
{
matches.Sort(StringComparer.Ordinal);
ambiguityNote = $"Type name '{name}' matched {matches.Count} loaded types " +
$"({string.Join(", ", matches.GetRange(0, Math.Min(matches.Count, 4)))}" +
(matches.Count > 4 ? ", ..." : "") +
$"); bound to '{chosen.FullName}'. Use the fully-qualified name to disambiguate.";
}
return chosen;
}
/// <summary>
/// Gate for retrying a previously failed type-resolution scan: true only when the set of
/// loaded assemblies has changed since the last attempt (recording a signature of the
/// current set, so the next failure waits for the next actual change). If the scan succeeds
/// the positive cache on the plan short-circuits all later polls and the recorded signature
/// is never consulted again.
/// </summary>
private static bool ShouldRescan(WaitConditionPlan plan)
{
var signature = AssemblySetSignature(PipelineUtils.GetLoadedAssemblies());
if (signature == plan.FailedScanAssemblySignature)
return false;
plan.FailedScanAssemblySignature = signature;
return true;
}
/// <summary>
/// Identity-based signature of a loaded-assembly set: combines each assembly's own
/// (identity) hash code, not just the set's <c>Count</c>. An unload-and-load-of-a-different-
/// assembly (e.g. a hot-reloaded script assembly) can leave the count unchanged while
/// swapping every member of the set — a count-only gate would then never re-attempt a scan
/// that just became resolvable.
/// </summary>
private static long AssemblySetSignature(IReadOnlyList<Assembly> assemblies)
{
unchecked
{
long hash = 17;
for (var i = 0; i < assemblies.Count; i++)
hash = hash * 31 + assemblies[i].GetHashCode();
return hash;
}
}
private static Type[] SafeGetTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types?.Where(t => t != null).ToArray() ?? Array.Empty<Type>();
}
catch
{
return Array.Empty<Type>();
}
}
/// <summary>
/// Pick the live instance a <c>findType</c> wait binds to. Persistent objects
/// (prefab/asset files on disk — <see cref="EditorUtility.IsPersistent"/>) and hidden
/// editor internals (<c>hideFlags != None</c>) are excluded so the wait observes the live
/// instance, never the asset. Scene instances are preferred over loose runtime objects;
/// if several candidates still remain, the lowest instance id wins deterministically and
/// the ambiguity is reported via <paramref name="note"/> on the wait result.
/// </summary>
private static Object FindInstance(Type type, out string note)
{
note = null;
if (!typeof(Object).IsAssignableFrom(type))
// A property of the resolved TYPE, not of whether an instance exists yet —
// permanent regardless of tolerate_missing.
throw new WaitEvaluationException(
$"findType '{type.FullName}' is not a UnityEngine.Object type; use a static member path or a 'target' handle.",
isPermanent: true);
var all = Resources.FindObjectsOfTypeAll(type);
if (all == null || all.Length == 0)
return null;
var candidates = new List<Object>();
foreach (var o in all)
{
if (o == null)
continue;
if (EditorUtility.IsPersistent(o))
continue; // a prefab or other asset on disk, not the live instance
if (o.hideFlags != HideFlags.None)
continue; // hidden editor internals / preview objects
candidates.Add(o);
}
if (candidates.Count == 0)
return null;
if (candidates.Count == 1)
return candidates[0];
var sceneInstances = candidates.Where(IsSceneInstance).ToList();
var pool = sceneInstances.Count > 0 ? sceneInstances : candidates;
// Version-stable ids: GetInstanceID() is obsolete-as-error from 6000.4.
var chosen = pool.OrderBy(o => PipelineUtils.GetObjectId(o).RawValue).First();
if (pool.Count > 1)
note = $"findType '{type.FullName}' matched {pool.Count} live instances; bound to instanceId {PipelineUtils.GetObjectId(chosen).RawValue} (lowest). Use 'target' to select one explicitly.";
return chosen;
}
private static bool IsSceneInstance(Object o)
{
var go = o as GameObject ?? (o as Component)?.gameObject;
return go != null && go.scene.IsValid();
}
/// <summary>
/// Read a single named field or property. The member lookup is memoized on the plan per
/// encountered runtime type, so only the first poll pays for reflection resolution.
/// Rejects write-only properties, indexers, and anything that is not a readable field/property.
/// </summary>
private static bool TryReadMember(WaitConditionPlan plan, int segmentIndex, Type lookup, string name,
bool isStatic, object instance, out object value, out Type type, out string error)
{
value = null;
type = null;
error = null;
if (!plan.TryGetCachedMember(lookup, segmentIndex, isStatic, out var member))
{
if (!TryResolveMember(lookup, name, isStatic, out member, out error))
return false;
plan.CacheMember(lookup, segmentIndex, isStatic, member);
}
if (member is PropertyInfo property)
{
value = property.GetValue(isStatic ? null : instance);
type = property.PropertyType;
return true;
}
var field = (FieldInfo)member;
value = field.GetValue(isStatic ? null : instance);
type = field.FieldType;
return true;
}
/// <summary>Resolve a named field or property, walking base types for inherited PUBLIC
/// members. Public-only is a deliberate security boundary (see <see cref="MemberFlags"/>) —
/// the base walk exists because Type.GetField/GetProperty with declared-only semantics
/// would miss inherited members, not to reach private state.</summary>
private static bool TryResolveMember(Type lookup, string name, bool isStatic,
out MemberInfo member, out string error)
{
member = null;
error = null;
var flags = MemberFlags | (isStatic ? BindingFlags.Static : BindingFlags.Instance) | BindingFlags.DeclaredOnly;
for (var t = lookup; t != null; t = t.BaseType)
{
var property = t.GetProperty(name, flags);
if (property != null)
{
if (!property.CanRead)
{
error = $"Member '{name}' on '{lookup.FullName}' is write-only and cannot be observed.";
return false;
}
if (property.GetIndexParameters().Length > 0)
{
error = $"Member '{name}' on '{lookup.FullName}' is an indexer and cannot be observed.";
return false;
}
member = property;
return true;
}
var field = t.GetField(name, flags);
if (field != null)
{
member = field;
return true;
}
}
error = $"No readable field or property '{name}' on type '{lookup.FullName}' " +
$"({(isStatic ? "static" : "instance")} members only; methods and write-only members are rejected).";
return false;
}
/// <summary>Coerce a JSON operand (JToken or boxed primitive/string) to the member's runtime type.</summary>
private static bool TryCoerceOperand(object raw, Type targetType, out object coerced, out string error)
{
coerced = null;
error = null;
if (raw == null)
{
error = "condition.value is required for this operator.";
return false;
}
try
{
var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType;
if (underlying.IsEnum)
{
var text = RawToString(raw);
if (!string.IsNullOrEmpty(text) && !LooksNumeric(text))
{
coerced = Enum.Parse(underlying, text, ignoreCase: true);
return true;
}
var numeric = Convert.ToInt64(RawToScalar(raw));
coerced = Enum.ToObject(underlying, numeric);
return true;
}
var token = raw as JToken ?? JToken.FromObject(raw);
coerced = token.ToObject(underlying);
return true;
}
catch (Exception ex)
{
error = $"Could not coerce value '{RawToString(raw)}' to {targetType.Name}: {ex.Message}";
return false;
}
}
private static string RawToString(object raw)
{
if (raw is JToken token)
return token.Type == JTokenType.Null ? null : token.ToObject<string>();
return raw?.ToString();
}
private static object RawToScalar(object raw)
{
if (raw is JValue value)
return value.Value;
return raw;
}
private static bool LooksNumeric(string text)
{
return long.TryParse(text, out _) || double.TryParse(text, out _);
}
private static bool ValuesEqual(object a, object b)
{
if (a == null && b == null)
return true;
if (a == null || b == null)
return false;
if (IsNumeric(a) && IsNumeric(b))
{
// A float member must be compared at float precision: widening float to double
// manufactures noise digits (0.9f -> 0.90000003576...) that never equal the double
// operand parsed from JSON, so 'equals' on a float would never match.
if (a is float || b is float)
return Convert.ToSingle(a).Equals(Convert.ToSingle(b));
if (a is double || b is double)
return Convert.ToDouble(a).Equals(Convert.ToDouble(b));
// Both integral/decimal: compare exactly via decimal — a double round-trip
// collapses distinct values above 2^53 (9007199254740993 == 9007199254740992.0d).
return Convert.ToDecimal(a).Equals(Convert.ToDecimal(b));
}
if (a.Equals(b))
return true;
// Enum-vs-name / cross-type tolerance (e.g. an enum compared to its string name).
return string.Equals(a.ToString(), b.ToString(), StringComparison.Ordinal);
}
/// <summary>
/// Ordering comparison that stays exact for integral/decimal operands (decimal compare —
/// double collapses distinct values above 2^53) and uses double only when a floating-point
/// side or an enum is genuinely involved.
/// </summary>
private static bool TryCompareNumeric(object a, object b, out int comparison, out string error)
{
comparison = 0;
if (IsExactNumeric(a) && IsExactNumeric(b))
{
try
{
comparison = Convert.ToDecimal(a).CompareTo(Convert.ToDecimal(b));
error = null;
return true;
}
catch (Exception ex)
{
error = $"Values '{a}' and '{b}' are not comparable: {ex.Message}";
return false;
}
}
if (!TryToDouble(a, out var da, out error))
return false;
if (!TryToDouble(b, out var db, out error))
return false;
comparison = da.CompareTo(db);
return true;
}
private static bool IsExactNumeric(object value) =>
value is sbyte || value is byte || value is short || value is ushort ||
value is int || value is uint || value is long || value is ulong || value is decimal;
private static bool TryToDouble(object value, out double result, out string error)
{
result = 0;
error = null;
if (value == null)
{
error = "Cannot compare a null value numerically.";
return false;
}
if (IsNumeric(value) || value is Enum)
{
try
{
result = Convert.ToDouble(value);
return true;
}
catch (Exception ex)
{
error = $"Value '{value}' is not numeric: {ex.Message}";
return false;
}
}
error = $"greaterThan/lessThan require a numeric member; got '{value.GetType().Name}'.";
return false;
}
private static bool IsNumeric(object value)
{
switch (value)
{
case sbyte _:
case byte _:
case short _:
case ushort _:
case int _:
case uint _:
case long _:
case ulong _:
case float _:
case double _:
case decimal _:
return true;
default:
return false;
}
}
}
}