java.lang.Object
com.darkcollective.relix.processor.eval.ValueComparator
All Implemented Interfaces:
Comparator<Value>

public final class ValueComparator extends Object implements Comparator<Value>
Total ordering over Value instances for use in sort and aggregation operators.

Two singleton instances are provided:

  • NULLS_LAST — NULL sorts after all non-null values (standard SQL ASC behaviour).
  • NULLS_FIRST — NULL sorts before all non-null values (standard SQL DESC behaviour).

This class is the single authority on when two values are equal or ordered. Equality (equal(com.darkcollective.relix.value.Value, com.darkcollective.relix.value.Value)), ordering (compareNonNull(com.darkcollective.relix.value.Value, com.darkcollective.relix.value.Value)) and the hash-bucket token (joinToken(com.darkcollective.relix.value.Value)) all read one coercion rule, so a pair the selection operator matches is a pair the join operator matches. They did once disagree: each of the two evaluators grew the coercion its own callers needed — strings against booleans here, strings against temporals there — and a predicate written as a selection and the same predicate written as a join then gave different answers with no diagnostic.

Non-null values of the same runtime type are ordered naturally: NumberValue by BigDecimal.compareTo(java.math.BigDecimal), StringValue by code point (CodePoints.compare(java.lang.String, java.lang.String)), BooleanValue with false before true, and the temporal values (DateValue, TimeValue, TimestampValue, DurationValue) by their java.time natural order. Comparing values of different types throws EvaluationException.

A string is taken to mean what it spells. Text naming a boolean ("true", "TRUE") or spelling a temporal ("2024-01-15", "2024-01-15T11:00:00+01:00") is that value here, whatever it is being compared against — which is what lets a CSV or inline-table cell, inferred as STRING, meet a typed literal. Two spellings of one value are therefore equal, and are ordered by the value rather than as text: an offset timestamp sorts by the instant it denotes, not by its leading digits.

The consequence to know about is that such text is no longer a STRING for the purpose of ordering, so a column holding "2024-01-15" beside "pending" holds a DATE beside a STRING and has no order. Cast it to settle what the column holds.

  • Field Details

    • NULLS_LAST

      public static final ValueComparator NULLS_LAST
      NULL values sort after all non-null values.
    • NULLS_FIRST

      public static final ValueComparator NULLS_FIRST
      NULL values sort before all non-null values.
  • Method Details

    • compare

      public int compare(Value a, Value b)
      Specified by:
      compare in interface Comparator<Value>
    • canonical

      public static Value canonical(Value v)
      Returns the value a string denotes — the temporal it spells or the boolean it names — or v unchanged when it is not a string or denotes neither.

      Inline-table, CSV and JSON cells all infer as STRING, so a column holding timestamps or flags reaches the executor as StringValue while the literal it is compared against is properly typed. Both coercions exist for that one reason — "2024-01-15T10:00:00" against a TIMESTAMP and "true" against a BOOLEAN — and both belong here rather than at a call site, because a coercion only one caller knows about is a way for two callers to disagree.

      It reads one value and no partner, and that is the whole design. A rule that coerced a string only when the other operand was typed made equality depend on the pair: a TIMESTAMP equalled both "2024-01-15T10:00:00Z" and "2024-01-15T11:00:00+01:00" while those two, being both strings, were compared as text and were unequal. That is precisely the substitutability axiom Comparator requires, and breaking it does not raise an error — it makes a sort's result depend on the order the rows arrived in, and can place a later timestamp before an earlier one. No pairwise rule can avoid this: equivalence classes have to be a property of a value alone.

      The price is paid by a STRING column that mixes text denoting a value with text denoting nothing. "2024-01-15" is a DATE here, so ordering it against "pending" is ordering a DATE against a STRING, which has no answer and says so. Cast the column to settle what it holds.

      It is public because the operators that deduplicate need it as much as the ones that match. δ, γ and the set operations build a key and compare it with Value.equals, which is record equality and knows nothing of this rule — so σ called a string-spelled boolean equal to a BOOLEAN while δ kept the two apart, and the duplicate it returned printed identically to the row beside it. Keying on the canonical form is what closes that, and it works with plain list equality because Value.equals is already right within a type (NumberValue overriding it so that 5 and 5.0 are one number); this rule is what settles it across types.

      A key is not an output. The canonical form decides which rows group together and must never replace the value the operator emits, or grouping a STRING column of dates would start returning DATEs.

    • equal

      public static boolean equal(Value a, Value b)
      Returns whether two values are equal, canonicalised per canonical(com.darkcollective.relix.value.Value).

      Unlike compareNonNull(com.darkcollective.relix.value.Value, com.darkcollective.relix.value.Value) this never throws: values of genuinely different types are simply not equal. That is what an equality test wants — a join looking for matching rows should skip a mismatched pair, not abort the query — and it is why a match test must call this rather than compare against zero, which conflates "different" with "unorderable".

      NULL is equal to nothing, itself included.

    • joinToken

      public static String joinToken(Value v)
      Returns the hash-bucket token for v — the form in which any two values equal(com.darkcollective.relix.value.Value, com.darkcollective.relix.value.Value) accepts are guaranteed to produce the same string.

      A hash join buckets rows by this token and then re-checks each candidate pair, so the token is a pre-filter and carries a one-way obligation: it may put more pairs in a bucket than actually match (the re-check discards them, costing only time), but it must never separate a pair that would match — those rows are then silently dropped from the result.

      That is why this is not simply Value.asDisplayString(). The display form already collapses NUMBER scale (5 and 5.0 both render "5"), but it keeps a string apart from the value canonical(com.darkcollective.relix.value.Value) turns it into — "2024-01-15T10:00:00" against an Instant rendering "2024-01-15T10:00:00Z", or "TRUE" against true. Reading the same canonical form closes that gap, and reading it from the same method is what keeps the token and equal(com.darkcollective.relix.value.Value, com.darkcollective.relix.value.Value) from drifting apart — they were two statements of one rule until they were not.

    • compareNonNull

      public static int compareNonNull(Value a, Value b)
      Compares two non-null values, canonicalised per canonical(com.darkcollective.relix.value.Value).
      Throws:
      EvaluationException - if the values have no shared order