kScalarFunctions top-level property

Map<String, ScalarFn> kScalarFunctions
final

Implementation

final Map<String, ScalarFn> kScalarFunctions = <String, ScalarFn>{
  'UPPER': (a) => _propagateNull(a, () => a[0].toString().toUpperCase()),
  'LOWER': (a) => _propagateNull(a, () => a[0].toString().toLowerCase()),
  'LENGTH': (a) => _propagateNull(a, () {
        final v = a[0]!;
        // SQLite: LENGTH on a BLOB is the byte count, on TEXT the
        // character count.
        if (v is List<int>) return v.length;
        return v.toString().length;
      }),
  'CHAR_LENGTH': (a) => kScalarFunctions['LENGTH']!(a),
  'CHARACTER_LENGTH': (a) => kScalarFunctions['LENGTH']!(a),
  // OCTET_LENGTH: UTF-8 byte length for text, byte length for blobs.
  'OCTET_LENGTH': (a) => _propagateNull(a, () {
        final v = a[0]!;
        if (v is List<int>) return v.length;
        return utf8.encode(v.toString()).length;
      }),
  // BIT_LENGTH: OCTET_LENGTH * 8.
  'BIT_LENGTH': (a) => _propagateNull(a, () {
        final v = a[0]!;
        if (v is List<int>) return v.length * 8;
        return utf8.encode(v.toString()).length * 8;
      }),
  // LEAST(a, b, ...) -- smallest non-NULL value; returns NULL if all NULL.
  'LEAST': (a) {
    Object? best;
    for (final v in a) {
      if (v == null) continue;
      if (best == null || sqlCompare(v, best) < 0) best = v;
    }
    return best;
  },
  // GREATEST(a, b, ...) -- largest non-NULL value.
  'GREATEST': (a) {
    Object? best;
    for (final v in a) {
      if (v == null) continue;
      if (best == null || sqlCompare(v, best) > 0) best = v;
    }
    return best;
  },
  'TRIM': (a) => _propagateNull(a, () => a[0].toString().trim()),
  'LTRIM': (a) => _propagateNull(
        a,
        () => a[0].toString().replaceFirst(RegExp(r'^\s+'), ''),
      ),
  'RTRIM': (a) => _propagateNull(
        a,
        () => a[0].toString().replaceFirst(RegExp(r'\s+$'), ''),
      ),
  'SUBSTR': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final s = a[0].toString();
    if (a.length < 2) return s;
    final start = (a[1] as num).toInt();
    // SQL is 1-based; negative offsets count from the end.
    var idx = start > 0 ? start - 1 : (s.length + start).clamp(0, s.length);
    if (idx < 0) idx = 0;
    if (idx >= s.length) return '';
    if (a.length >= 3 && a[2] != null) {
      var len = (a[2] as num).toInt();
      if (len < 0) len = 0;
      final end = (idx + len).clamp(0, s.length);
      return s.substring(idx, end);
    }
    return s.substring(idx);
  },
  'SUBSTRING': (a) => kScalarFunctions['SUBSTR']!(a),
  // LEFT(s, n) -- first n characters.
  'LEFT': (a) => _propagateNull(a, () {
        final s = a[0].toString();
        final n = (a[1] as num).toInt();
        if (n <= 0) return '';
        return n >= s.length ? s : s.substring(0, n);
      }),
  // RIGHT(s, n) -- last n characters.
  'RIGHT': (a) => _propagateNull(a, () {
        final s = a[0].toString();
        final n = (a[1] as num).toInt();
        if (n <= 0) return '';
        return n >= s.length ? s : s.substring(s.length - n);
      }),
  // POSITION(needle IN haystack) is parsed as POSITION(needle, haystack)
  // here; returns 1-based index, 0 when not found, NULL on NULL input.
  'POSITION': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return null;
    final needle = a[0].toString();
    final hay = a[1].toString();
    if (needle.isEmpty) return 1;
    return hay.indexOf(needle) + 1;
  },
  // OVERLAY(s, replacement, start [, length]) -- replace `length`
  // characters of `s` starting at 1-based `start` with `replacement`.
  // When length is omitted it defaults to the length of `replacement`.
  'OVERLAY': (a) {
    if (a.length < 3 || a[0] == null || a[1] == null || a[2] == null) {
      return null;
    }
    final s = a[0].toString();
    final repl = a[1].toString();
    final start = (a[2] as num).toInt();
    final length =
        a.length >= 4 && a[3] != null ? (a[3] as num).toInt() : repl.length;
    final idx = (start - 1).clamp(0, s.length);
    final endIdx = (idx + length).clamp(0, s.length);
    return s.substring(0, idx) + repl + s.substring(endIdx);
  },
  // REVERSE(s) -- reverses a string by Unicode code points.
  'REVERSE': (a) => _propagateNull(a, () {
        final s = a[0].toString();
        return String.fromCharCodes(s.runes.toList().reversed);
      }),
  // REPEAT(s, n) -- string repetition; negative or zero count returns ''.
  'REPEAT': (a) => _propagateNull(a, () {
        final s = a[0].toString();
        final n = (a[1] as num).toInt();
        if (n <= 0) return '';
        return s * n;
      }),
  // ASCII(s) -- code point of the first character. SQLite returns the
  // codepoint of the first UTF-8 byte in the input; we mirror Dart's
  // String.codeUnitAt(0) which is the UTF-16 code unit, equivalent for
  // ASCII inputs.
  'ASCII': (a) => _propagateNull(a, () {
        final s = a[0].toString();
        return s.isEmpty ? null : s.codeUnitAt(0);
      }),
  // CHR(n) -- single-character string from a Unicode codepoint.
  'CHR': (a) => _propagateNull(a, () {
        return String.fromCharCode((a[0] as num).toInt());
      }),
  // SPACE(n) -- string of n spaces.
  'SPACE': (a) => _propagateNull(a, () {
        final n = (a[0] as num).toInt();
        return n <= 0 ? '' : ' ' * n;
      }),
  // INITCAP(s) -- title-case each whitespace-separated word.
  'INITCAP': (a) => _propagateNull(a, () {
        final s = a[0].toString();
        if (s.isEmpty) return s;
        final out = StringBuffer();
        var nextUpper = true;
        for (var i = 0; i < s.length; i++) {
          final c = s[i];
          if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
            out.write(c);
            nextUpper = true;
            continue;
          }
          out.write(nextUpper ? c.toUpperCase() : c.toLowerCase());
          nextUpper = false;
        }
        return out.toString();
      }),
  'REPLACE': (a) => _propagateNull(
        a,
        () => a[0].toString().replaceAll(a[1].toString(), a[2].toString()),
      ),
  'CONCAT': (a) => a.map((v) => v ?? '').join(),
  // CONCAT_WS(sep, args...) — joins non-NULL args with the given separator.
  // If sep is NULL, returns NULL (matches MySQL/PostgreSQL semantics).
  'CONCAT_WS': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final sep = a[0].toString();
    final parts = <String>[];
    for (var i = 1; i < a.length; i++) {
      final v = a[i];
      if (v == null) continue;
      parts.add(v.toString());
    }
    return parts.join(sep);
  },
  'COALESCE': (a) {
    for (final v in a) {
      if (v != null) return v;
    }
    return null;
  },
  'IFNULL': (a) => a[0] ?? (a.length > 1 ? a[1] : null),
  'NVL': (a) => a[0] ?? (a.length > 1 ? a[1] : null),
  'NVL2': (a) {
    if (a.length < 3) return null;
    return a[0] != null ? a[1] : a[2];
  },
  'DECODE': (a) {
    // DECODE(expr, search1, result1, [search2, result2, ...] [, default])
    if (a.isEmpty) return null;
    final expr = a[0];
    var i = 1;
    while (i + 1 < a.length) {
      final s = a[i];
      if ((expr == null && s == null) ||
          (expr != null && s != null && sqlEq(expr, s))) {
        return a[i + 1];
      }
      i += 2;
    }
    return i < a.length ? a[i] : null;
  },
  'NULLIF': (a) {
    if (a.length < 2 || a[0] == null) return a.isEmpty ? null : a[0];
    if (a[1] == null) return a[0];
    return sqlEq(a[0]!, a[1]!) ? null : a[0];
  },
  'ABS': (a) => _propagateNull(a, () => (a[0] as num).abs()),
  'ROUND': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final v = (a[0] as num).toDouble();
    final digits = a.length > 1 && a[1] != null ? (a[1] as num).toInt() : 0;
    final p = _pow10(digits);
    return (v * p).round() / p;
  },
  'MOD': (a) => _propagateNull(a, () => (a[0] as num) % (a[1] as num)),
  'FLOOR': (a) => _propagateNull(a, () => (a[0] as num).floor()),
  'CEIL': (a) => _propagateNull(a, () => (a[0] as num).ceil()),
  'CEILING': (a) => _propagateNull(a, () => (a[0] as num).ceil()),
  'SQRT': (a) => _propagateNull(a, () {
        final v = (a[0] as num).toDouble();
        if (v < 0) return null;
        return _sqrt(v);
      }),
  'POWER': (a) => _propagateNull(
        a,
        () => _intPow((a[0] as num).toDouble(), (a[1] as num).toDouble()),
      ),
  'POW': (a) => kScalarFunctions['POWER']!(a),
  'SIGN': (a) => _propagateNull(a, () {
        final v = (a[0] as num).toDouble();
        if (v == 0) return 0;
        return v > 0 ? 1 : -1;
      }),
  // SQLite 3.35+ math functions. All operate in double precision and
  // return NULL on NULL input. Domain errors (e.g. LN(0), SQRT(-1))
  // return NULL to match SQLite.
  'PI': (a) => math.pi,
  // GROUPING(expr) — context-sensitive. The select executor rewrites
  // results from GROUPING SETS / ROLLUP / CUBE so this default 0
  // applies only when the expression IS in the current grouping set.
  'GROUPING': (a) => 0,
  'IIF': (a) {
    if (a.length < 3) return null;
    final c = a[0];
    final truthy = c is bool
        ? c
        : c is num
            ? c != 0
            : c != null;
    return truthy ? a[1] : a[2];
  },
  'CBRT': (a) => _propagateNull(a, () {
        final v = (a[0] as num).toDouble();
        if (v < 0) return -math.pow(-v, 1 / 3).toDouble();
        return math.pow(v, 1 / 3).toDouble();
      }),
  'EXP': (a) => _propagateNull(a, () => math.exp((a[0] as num).toDouble())),
  'LN': (a) => _propagateNull(a, () {
        final v = (a[0] as num).toDouble();
        if (v <= 0) return null;
        return math.log(v);
      }),
  'LOG10': (a) => _propagateNull(a, () {
        final v = (a[0] as num).toDouble();
        if (v <= 0) return null;
        return math.log(v) / math.ln10;
      }),
  'LOG2': (a) => _propagateNull(a, () {
        final v = (a[0] as num).toDouble();
        if (v <= 0) return null;
        return math.log(v) / math.ln2;
      }),
  // LOG(x) == LOG10(x); LOG(b, x) == log base b of x (SQLite semantics).
  'LOG': (a) {
    if (a.isEmpty || a[0] == null) return null;
    if (a.length == 1) {
      final v = (a[0] as num).toDouble();
      if (v <= 0) return null;
      return math.log(v) / math.ln10;
    }
    if (a[1] == null) return null;
    final b = (a[0] as num).toDouble();
    final v = (a[1] as num).toDouble();
    if (b <= 0 || b == 1 || v <= 0) return null;
    return math.log(v) / math.log(b);
  },
  'SIN': (a) => _propagateNull(a, () => math.sin((a[0] as num).toDouble())),
  'COS': (a) => _propagateNull(a, () => math.cos((a[0] as num).toDouble())),
  'TAN': (a) => _propagateNull(a, () => math.tan((a[0] as num).toDouble())),
  'COT': (a) => _propagateNull(a, () {
        final t = math.tan((a[0] as num).toDouble());
        if (t == 0) return null;
        return 1.0 / t;
      }),
  'ACOT': (a) => _propagateNull(a, () {
        final v = (a[0] as num).toDouble();
        if (v == 0) return math.pi / 2;
        return math.atan(1.0 / v);
      }),
  'ASIN': (a) => _propagateNull(a, () {
        final v = (a[0] as num).toDouble();
        if (v < -1 || v > 1) return null;
        return math.asin(v);
      }),
  'ACOS': (a) => _propagateNull(a, () {
        final v = (a[0] as num).toDouble();
        if (v < -1 || v > 1) return null;
        return math.acos(v);
      }),
  'ATAN': (a) => _propagateNull(a, () => math.atan((a[0] as num).toDouble())),
  'ATAN2': (a) => _propagateNull(
        a,
        () => math.atan2((a[0] as num).toDouble(), (a[1] as num).toDouble()),
      ),
  'SINH': (a) => _propagateNull(a, () {
        final x = (a[0] as num).toDouble();
        return (math.exp(x) - math.exp(-x)) / 2;
      }),
  'COSH': (a) => _propagateNull(a, () {
        final x = (a[0] as num).toDouble();
        return (math.exp(x) + math.exp(-x)) / 2;
      }),
  'TANH': (a) => _propagateNull(a, () {
        final x = (a[0] as num).toDouble();
        if (x > 20) return 1.0;
        if (x < -20) return -1.0;
        final ep = math.exp(x);
        final en = math.exp(-x);
        return (ep - en) / (ep + en);
      }),
  'ASINH': (a) => _propagateNull(a, () {
        final x = (a[0] as num).toDouble();
        return math.log(x + math.sqrt(x * x + 1));
      }),
  'ACOSH': (a) => _propagateNull(a, () {
        final x = (a[0] as num).toDouble();
        if (x < 1) return null;
        return math.log(x + math.sqrt(x * x - 1));
      }),
  'ATANH': (a) => _propagateNull(a, () {
        final x = (a[0] as num).toDouble();
        if (x <= -1 || x >= 1) return null;
        return 0.5 * math.log((1 + x) / (1 - x));
      }),
  'RADIANS': (a) =>
      _propagateNull(a, () => (a[0] as num).toDouble() * math.pi / 180),
  'DEGREES': (a) =>
      _propagateNull(a, () => (a[0] as num).toDouble() * 180 / math.pi),
  'TRUNC': (a) => _propagateNull(a, () => (a[0] as num).truncate()),
  // REGEXP family. SQLite's REGEXP operator desugars to regexp(pat, val);
  // we accept both forms. Pattern is Dart's RegExp (PCRE-like).
  'REGEXP': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return null;
    return RegExp(a[0].toString()).hasMatch(a[1].toString());
  },
  'REGEXP_LIKE': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return null;
    return RegExp(a[1].toString()).hasMatch(a[0].toString());
  },
  'REGEXP_SUBSTR': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return null;
    final m = RegExp(a[1].toString()).firstMatch(a[0].toString());
    return m?.group(0);
  },
  'REGEXP_REPLACE': (a) {
    if (a.length < 3 || a[0] == null || a[1] == null || a[2] == null) {
      return null;
    }
    return a[0].toString().replaceAll(RegExp(a[1].toString()), a[2].toString());
  },
  'RANDOM': (a) => _rng.nextInt(1 << 31),
  'INSTR': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return null;
    final hay = a[0].toString();
    final needle = a[1].toString();
    if (needle.isEmpty) return 1;
    return hay.indexOf(needle) + 1; // 1-based; 0 == not found
  },
  'LPAD': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final s = a[0].toString();
    final n = a.length > 1 && a[1] != null ? (a[1] as num).toInt() : s.length;
    final pad = a.length > 2 && a[2] != null ? a[2].toString() : ' ';
    if (s.length >= n || pad.isEmpty) {
      return s.length > n ? s.substring(0, n) : s;
    }
    final buf = StringBuffer();
    while (buf.length + s.length < n) {
      buf.write(pad);
    }
    final padded = buf.toString();
    return padded.substring(0, n - s.length) + s;
  },
  'RPAD': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final s = a[0].toString();
    final n = a.length > 1 && a[1] != null ? (a[1] as num).toInt() : s.length;
    final pad = a.length > 2 && a[2] != null ? a[2].toString() : ' ';
    if (s.length >= n || pad.isEmpty) {
      return s.length > n ? s.substring(0, n) : s;
    }
    final buf = StringBuffer(s);
    while (buf.length < n) {
      buf.write(pad);
    }
    return buf.toString().substring(0, n);
  },
  // --- Encoding / codepoint helpers ------------------------------------
  // HEX(X) — uppercase hex of a BLOB or string's UTF-8 bytes.
  'HEX': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final v = a[0]!;
    final bytes = v is List<int> ? v : utf8.encode(v.toString());
    final sb = StringBuffer();
    for (final b in bytes) {
      sb.write((b & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase());
    }
    return sb.toString();
  },
  // ZEROBLOB(n) -- BLOB of n zero bytes.
  'ZEROBLOB': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final n = (a[0] as num).toInt();
    if (n < 0) return Uint8List(0);
    return Uint8List(n);
  },
  // RANDOMBLOB(n) -- BLOB of n random bytes.
  'RANDOMBLOB': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final n = (a[0] as num).toInt();
    if (n <= 0) return <int>[];
    return List<int>.generate(n, (_) => _rng.nextInt(256));
  },
  // QUOTE(X) -- SQL literal form of X. Strings become 'escaped',
  // BLOBs become X'hex', NULL becomes 'NULL', numbers stringified.
  'QUOTE': (a) {
    if (a.isEmpty) return null;
    final v = a[0];
    if (v == null) return 'NULL';
    if (v is List<int>) {
      final sb = StringBuffer("X'");
      for (final b in v) {
        sb.write((b & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase());
      }
      sb.write("'");
      return sb.toString();
    }
    if (v is num || v is bool) return v.toString();
    return "'${v.toString().replaceAll("'", "''")}'";
  },
  // LIKELY(X), UNLIKELY(X), LIKELIHOOD(X, _) -- optimizer hints; we just
  // return X. The constant probability arg to LIKELIHOOD is ignored.
  'LIKELY': (a) => a.isEmpty ? null : a[0],
  'UNLIKELY': (a) => a.isEmpty ? null : a[0],
  'LIKELIHOOD': (a) => a.isEmpty ? null : a[0],
  // SOUNDEX(s) -- classic Soundex algorithm; returns 4-char code.
  // NULL or empty string returns '?000' (matches SQLite's odd convention).
  'SOUNDEX': (a) {
    if (a.isEmpty || a[0] == null) return '?000';
    final s = a[0].toString();
    if (s.isEmpty) return '?000';
    int upper(int cu) => (cu >= 0x61 && cu <= 0x7A) ? cu - 0x20 : cu;
    String code(int cu) {
      switch (upper(cu)) {
        case 0x42:
        case 0x46:
        case 0x50:
        case 0x56:
          return '1';
        case 0x43:
        case 0x47:
        case 0x4A:
        case 0x4B:
        case 0x51:
        case 0x53:
        case 0x58:
        case 0x5A:
          return '2';
        case 0x44:
        case 0x54:
          return '3';
        case 0x4C:
          return '4';
        case 0x4D:
        case 0x4E:
          return '5';
        case 0x52:
          return '6';
        default:
          return '';
      }
    }

    bool isHW(int cu) {
      final u = upper(cu);
      return u == 0x48 || u == 0x57; // H or W
    }

    final out = StringBuffer(String.fromCharCode(upper(s.codeUnitAt(0))));
    var prev = code(s.codeUnitAt(0));
    for (var i = 1; i < s.length && out.length < 4; i++) {
      final cu = s.codeUnitAt(i);
      if (isHW(cu)) {
        // H and W are transparent: skip without resetting prev.
        continue;
      }
      final c = code(cu);
      if (c.isEmpty) {
        // Vowel or other separator -- don't emit, but reset prev so the
        // next consonant can repeat its code.
        prev = '';
        continue;
      }
      if (c != prev) out.write(c);
      prev = c;
    }
    while (out.length < 4) {
      out.write('0');
    }
    return out.toString();
  },
  // BASE64(blob_or_text) -- standard Base64 encoding.
  'BASE64': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final v = a[0]!;
    final bytes = v is List<int> ? v : utf8.encode(v.toString());
    return base64.encode(bytes);
  },
  // UNBASE64(text) -- decodes Base64 string back to BLOB; returns NULL
  // on malformed input.
  'UNBASE64': (a) {
    if (a.isEmpty || a[0] == null) return null;
    try {
      return base64.decode(a[0].toString());
    } catch (_) {
      return null;
    }
  },
  // UNHEX(X[, ignored]) — inverse of HEX. Returns NULL on malformed input
  // (matches SQLite). Optional second arg lists characters to skip; we
  // honor the SQLite default of allowing whitespace.
  'UNHEX': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final s = a[0].toString();
    final ignore = a.length > 1 && a[1] != null ? a[1].toString() : '';
    final bytes = <int>[];
    int? pending;
    for (final cu in s.codeUnits) {
      final ch = String.fromCharCode(cu);
      if (ignore.contains(ch)) continue;
      final d = _hexDigit(cu);
      if (d < 0) return null;
      if (pending == null) {
        pending = d;
      } else {
        bytes.add((pending << 4) | d);
        pending = null;
      }
    }
    if (pending != null) return null;
    return bytes;
  },
  // UNICODE(X) — codepoint of the first character of X, or NULL if X is
  // null/empty.
  'UNICODE': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final s = a[0].toString();
    if (s.isEmpty) return null;
    return s.runes.first;
  },
  // CHAR(X1, X2, ...) — string formed from the given Unicode codepoints.
  'CHAR': (a) {
    final cps = <int>[];
    for (final v in a) {
      if (v == null) continue;
      cps.add((v as num).toInt());
    }
    return String.fromCharCodes(cps);
  },
  // PRINTF(format, args...) and its alias FORMAT(...). Implements the
  // SQLite printf subset: %d %i %u %x %X %o %c %s %f %e %g %p %% %q %Q
  // with optional flags (`-+ 0#`), width, and precision. NULL format
  // returns NULL; NULL substitution args are rendered as 'NULL' for %s
  // and 0 for numerics, matching SQLite.
  'PRINTF': (a) {
    if (a.isEmpty || a[0] == null) return null;
    return _sqlitePrintf(a[0].toString(), a.sublist(1));
  },
  'FORMAT': (a) => kScalarFunctions['PRINTF']!(a),
  // --- Datetime --------------------------------------------------------
  'CURRENT_TIMESTAMP': (a) => _fmtDateTime(DateTime.now().toUtc(), full: true),
  'CURRENT_DATE': (a) => _fmtDate(DateTime.now().toUtc()),
  'CURRENT_TIME': (a) => _fmtTime(DateTime.now().toUtc()),
  'DATE': (a) => _datetimeFn(a, kind: _DTKind.date),
  'TIME': (a) => _datetimeFn(a, kind: _DTKind.time),
  'DATETIME': (a) => _datetimeFn(a, kind: _DTKind.full),
  'STRFTIME': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final fmt = a[0].toString();
    final dt = _resolveDateTime(a.sublist(1));
    if (dt == null) return null;
    return _strftime(fmt, dt);
  },
  // TIMEDIFF(a, b) -- SQLite 3.43+. Returns the difference (a - b) as a
  // datetime string with leading sign: '+/-YYYY-MM-DD HH:MM:SS.SSS'.
  'TIMEDIFF': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return null;
    final da = _resolveDateTime([a[0]]);
    final db = _resolveDateTime([a[1]]);
    if (da == null || db == null) return null;
    final diffMicros = da.difference(db).inMicroseconds;
    final neg = diffMicros < 0;
    var abs = diffMicros.abs();
    final days = abs ~/ Duration.microsecondsPerDay;
    abs -= days * Duration.microsecondsPerDay;
    final hours = abs ~/ Duration.microsecondsPerHour;
    abs -= hours * Duration.microsecondsPerHour;
    final mins = abs ~/ Duration.microsecondsPerMinute;
    abs -= mins * Duration.microsecondsPerMinute;
    final secs = abs ~/ Duration.microsecondsPerSecond;
    abs -= secs * Duration.microsecondsPerSecond;
    final ms = abs ~/ 1000;
    String two(int v) => v.toString().padLeft(2, '0');
    String three(int v) => v.toString().padLeft(3, '0');
    final yearDays = days; // expressed as days only; year/month not split.
    return '${neg ? '-' : '+'}0000-00-${two(yearDays)} '
        '${two(hours)}:${two(mins)}:${two(secs)}.${three(ms)}';
  },
  'JULIANDAY': (a) {
    final dt = _resolveDateTime(a);
    if (dt == null) return null;
    return _toJulianDay(dt);
  },
  'UNIXEPOCH': (a) {
    final dt = _resolveDateTime(a);
    if (dt == null) return null;
    return dt.millisecondsSinceEpoch ~/ 1000;
  },
  'TYPEOF': (a) {
    final v = a.isEmpty ? null : a[0];
    if (v == null) return 'null';
    if (v is int) return 'integer';
    if (v is double) return 'real';
    if (v is bool) return 'integer';
    if (v is String) return 'text';
    return 'blob';
  },
  // ---- JSON1 (minimal) -----------------------------------------------------
  'JSON': (a) => _propagateNull(a, () {
        // Validate + reformat (canonical JSON encoding).
        final v = jsonDecode(a[0].toString());
        return jsonEncode(v);
      }),
  'JSON_VALID': (a) {
    if (a.isEmpty || a[0] == null) return 0;
    try {
      jsonDecode(a[0].toString());
      return 1;
    } catch (_) {
      return 0;
    }
  },
  'JSON_TYPE': (a) {
    if (a.isEmpty || a[0] == null) return null;
    Object? v;
    try {
      v = jsonDecode(a[0].toString());
    } catch (_) {
      return null;
    }
    if (a.length >= 2) {
      v = jsonPathLookup(v, a[1].toString());
    }
    if (v == null) return 'null';
    if (v is bool) return v ? 'true' : 'false';
    if (v is int) return 'integer';
    if (v is double) return 'real';
    if (v is String) return 'text';
    if (v is List) return 'array';
    if (v is Map) return 'object';
    return null;
  },
  'JSON_EXTRACT': (a) {
    if (a.isEmpty || a[0] == null) return null;
    Object? root;
    try {
      root = jsonDecode(a[0].toString());
    } catch (_) {
      return null;
    }
    // SQLite: with one path returns the SQL value (JSON unwrapped for
    // scalars, JSON text for arrays/objects). With multiple paths returns
    // a JSON array of values.
    if (a.length == 2) {
      final v = jsonPathLookup(root, a[1].toString());
      return _jsonScalarOrText(v);
    }
    final out = <Object?>[];
    for (var i = 1; i < a.length; i++) {
      out.add(jsonPathLookup(root, a[i].toString()));
    }
    return jsonEncode(out);
  },
  'JSON_ARRAY': (a) => jsonEncode(a.map(_jsonValueOf).toList()),
  'JSON_OBJECT': (a) {
    if (a.length.isOdd) {
      throw StateError('json_object requires an even number of arguments');
    }
    final m = <String, Object?>{};
    for (var i = 0; i < a.length; i += 2) {
      m[a[i].toString()] = _jsonValueOf(a[i + 1]);
    }
    return jsonEncode(m);
  },
  'JSON_ARRAY_LENGTH': (a) {
    if (a.isEmpty || a[0] == null) return null;
    Object? v;
    try {
      v = jsonDecode(a[0].toString());
    } catch (_) {
      return null;
    }
    if (a.length >= 2) v = jsonPathLookup(v, a[1].toString());
    return v is List ? v.length : 0;
  },
  'JSON_QUOTE': (a) {
    if (a.isEmpty) return 'null';
    return jsonEncode(a[0]);
  },
  // RAISE(IGNORE) / RAISE(ABORT|FAIL|ROLLBACK, 'msg'). Used in trigger
  // bodies to abort the host operation. Implemented by throwing a typed
  // exception that the trigger executor recognises.
  'RAISE': (a) {
    final action = (a.isEmpty ? 'ABORT' : a[0].toString()).toUpperCase();
    final msg = a.length >= 2 ? a[1]?.toString() ?? '' : '';
    throw RaiseException(action, msg);
  },
  // json_set / json_insert / json_replace / json_remove / json_patch take
  // (json, path, value, path, value, ...). Differences:
  //   - set:     overwrite if exists, create if not
  //   - insert:  create if not, never overwrite existing
  //   - replace: overwrite if exists, never create
  //   - remove:  delete each path
  'JSON_SET': (a) => _jsonMutate(a, overwrite: true, createMissing: true),
  'JSON_INSERT': (a) => _jsonMutate(a, overwrite: false, createMissing: true),
  'JSON_REPLACE': (a) => _jsonMutate(a, overwrite: true, createMissing: false),
  'JSON_REMOVE': (a) {
    if (a.isEmpty || a[0] == null) return null;
    Object? root;
    try {
      root = jsonDecode(a[0].toString());
    } catch (_) {
      return null;
    }
    for (var i = 1; i < a.length; i++) {
      if (a[i] == null) continue;
      root = jsonPathRemove(root, a[i].toString());
    }
    return jsonEncode(root);
  },
  'JSON_PATCH': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return null;
    Object? base;
    Object? patch;
    try {
      base = jsonDecode(a[0].toString());
      patch = jsonDecode(a[1].toString());
    } catch (_) {
      return null;
    }
    return jsonEncode(_rfc7396Merge(base, patch));
  },
  // Pretty-print JSON with 2-space indent (or a caller-supplied indent
  // string). Returns NULL on invalid JSON. SQLite 3.46+.
  'JSON_PRETTY': (a) {
    if (a.isEmpty || a[0] == null) return null;
    Object? v;
    try {
      v = jsonDecode(a[0].toString());
    } catch (_) {
      return null;
    }
    final indent = a.length >= 2 && a[1] != null ? a[1].toString() : '  ';
    return JsonEncoder.withIndent(indent).convert(v);
  },
  // -- JSONB family (SQLite 3.45+). The reference engine returns a
  // compact binary representation; our pure-Dart engine has no separate
  // JSONB encoder, so jsonb(x) returns canonical text JSON. Functions
  // that consume "JSONB" accept text JSON transparently because every
  // JSON parser here uses jsonDecode. This keeps round-trips identical
  // to SQLite for all values that are valid in either form.
  'JSONB': (a) {
    if (a.isEmpty || a[0] == null) return null;
    try {
      return jsonEncode(jsonDecode(a[0].toString()));
    } catch (_) {
      return jsonEncode(_jsonValueOf(a[0]));
    }
  },
  'JSONB_EXTRACT': (a) => kScalarFunctions['JSON_EXTRACT']!(a),
  'JSONB_ARRAY': (a) => kScalarFunctions['JSON_ARRAY']!(a),
  'JSONB_OBJECT': (a) => kScalarFunctions['JSON_OBJECT']!(a),
  'JSONB_SET': (a) => kScalarFunctions['JSON_SET']!(a),
  'JSONB_INSERT': (a) => kScalarFunctions['JSON_INSERT']!(a),
  'JSONB_REPLACE': (a) => kScalarFunctions['JSON_REPLACE']!(a),
  'JSONB_REMOVE': (a) => kScalarFunctions['JSON_REMOVE']!(a),
  'JSONB_PATCH': (a) => kScalarFunctions['JSON_PATCH']!(a),
  'JSONB_QUOTE': (a) => kScalarFunctions['JSON_QUOTE']!(a),
  'JSONB_TYPE': (a) => kScalarFunctions['JSON_TYPE']!(a),
  'JSONB_VALID': (a) => kScalarFunctions['JSON_VALID']!(a),
  'JSONB_ARRAY_LENGTH': (a) => kScalarFunctions['JSON_ARRAY_LENGTH']!(a),
  // ---- SQLite introspection / connection-state scalars -----------------
  // last_insert_rowid(): the ROWID of the most recent successful INSERT.
  // Returns 0 before any insert has occurred.
  'LAST_INSERT_ROWID': (a) => connStateLookup?.call('last_insert_rowid') ?? 0,
  // changes(): number of rows modified by the most recently completed
  // INSERT, UPDATE or DELETE statement.
  'CHANGES': (a) => connStateLookup?.call('changes') ?? 0,
  // total_changes(): total rows modified since the connection opened.
  'TOTAL_CHANGES': (a) => connStateLookup?.call('total_changes') ?? 0,
  // bit_count(x): population count (number of set bits in integer x).
  'BIT_COUNT': (a) => _propagateNull(a, () {
        var v = (a[0] as num).toInt();
        var c = 0;
        while (v != 0) {
          c += v & 1;
          v = v >>> 1;
        }
        return c;
      }),
  // load_extension/sqlite_log: present for compatibility but no-op.
  'LOAD_EXTENSION': (a) => null,
  'SQLITE_LOG': (a) => null,
  // database()/schema(): SQLite always reports 'main' for the default
  // schema; we don't track ATTACH-time DB names through statements yet.
  'DATABASE': (a) => 'main',
  'SCHEMA': (a) => 'main',
  // sqlite_version(): version string of the SQLite release this engine
  // targets for feature parity.
  'SQLITE_VERSION': (a) => kSqliteVersionString,
  // sqlite_source_id(): in real SQLite this is a build/source hash. We
  // return a stable identifier that names this engine.
  'SQLITE_SOURCE_ID': (a) =>
      'dart-db-server (parity target $kSqliteVersionString)',
  // sqlite_compileoption_used(opt): always 0 — no compile options.
  'SQLITE_COMPILEOPTION_USED': (a) => 0,
  // sqlite_compileoption_get(n): always NULL — no compile options.
  'SQLITE_COMPILEOPTION_GET': (a) => null,
  // sqlite_offset(col): SQLite returns the byte offset of the column's
  // value in the DB file, or NULL when not on disk. We have no stable
  // mapping for the in-memory engine; return NULL unconditionally.
  'SQLITE_OFFSET': (a) => null,
  // subtype(value): SQLite 3.45+. Returns the application-defined
  // subtype of an SQL value. We have no subtype machinery, so 0.
  'SUBTYPE': (a) => 0,
  // json_error_position(json): SQLite 3.42+. Returns 0 when the input
  // is a valid JSON value, otherwise the 1-based character position of
  // the first parse error. NULL input yields NULL.
  'JSON_ERROR_POSITION': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final s = a[0].toString();
    try {
      jsonDecode(s);
      return 0;
    } on FormatException catch (e) {
      final off = e.offset;
      return (off == null || off < 0) ? 1 : off + 1;
    }
  },
  // ---- FTS5 ranking ------------------------------------------------------
  'FTS5_TF': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return 0;
    return fts5TermFrequency(a[0].toString(), a[1].toString());
  },
  'BM25': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return 0;
    final k1 = a.length > 2 && a[2] != null ? (a[2] as num).toDouble() : 1.2;
    final b = a.length > 3 && a[3] != null ? (a[3] as num).toDouble() : 0.75;
    return fts5Bm25(a[0].toString(), a[1].toString(), k1: k1, b: b);
  },
  // Corpus-aware BM25: `BM25_CORPUS(text, query, 'table', 'column'[, k1[, b]])`.
  // Looks up the cached Fts5Index for the named table/column on the
  // active database (Database.current) and computes a properly
  // IDF-weighted, length-normalised BM25 score for [text]. Useful in
  // ORDER BY on fts5 virtual tables, e.g.:
  //   SELECT body FROM docs WHERE body MATCH 'cat'
  //   ORDER BY bm25_corpus(body, 'cat', 'docs', 'body') DESC
  'BM25_CORPUS': (a) {
    if (a.length < 4 ||
        a[0] == null ||
        a[1] == null ||
        a[2] == null ||
        a[3] == null) {
      return 0;
    }
    final lookup = fts5CorpusLookup;
    if (lookup == null) {
      // No active database context — fall back to single-doc BM25 so
      // the function is still useful at the Dart-API layer.
      return fts5Bm25(a[0].toString(), a[1].toString());
    }
    final k1 = a.length > 4 && a[4] != null ? (a[4] as num).toDouble() : 1.2;
    final b = a.length > 5 && a[5] != null ? (a[5] as num).toDouble() : 0.75;
    final idx = lookup(a[2].toString(), a[3].toString());
    if (idx == null) return 0;
    return idx.bm25Text(a[0].toString(), a[1].toString(), k1: k1, b: b);
  },
  // --- MySQL datetime aliases ----------------------------------------
  'NOW': (a) => _fmtDateTime(DateTime.now().toUtc(), full: true),
  'SYSDATE': (a) => _fmtDateTime(DateTime.now().toUtc(), full: true),
  'CURDATE': (a) => _fmtDate(DateTime.now().toUtc()),
  'CURTIME': (a) => _fmtTime(DateTime.now().toUtc()),
  'UTC_TIMESTAMP': (a) => _fmtDateTime(DateTime.now().toUtc(), full: true),
  'UTC_DATE': (a) => _fmtDate(DateTime.now().toUtc()),
  'UTC_TIME': (a) => _fmtTime(DateTime.now().toUtc()),
  'UNIX_TIMESTAMP': (a) {
    final dt = a.isEmpty ? DateTime.now().toUtc() : _resolveDateTime(a);
    if (dt == null) return null;
    return dt.millisecondsSinceEpoch ~/ 1000;
  },
  'FROM_UNIXTIME': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final secs = (a[0] as num).toInt();
    final dt = DateTime.fromMillisecondsSinceEpoch(secs * 1000, isUtc: true);
    if (a.length > 1 && a[1] != null) {
      return _strftime(_mysqlFmtToStrftime(a[1].toString()), dt);
    }
    return _fmtDateTime(dt, full: true);
  },
  'YEAR': (a) {
    final dt = _resolveDateTime(a);
    return dt?.year;
  },
  'MONTH': (a) {
    final dt = _resolveDateTime(a);
    return dt?.month;
  },
  'DAY': (a) {
    final dt = _resolveDateTime(a);
    return dt?.day;
  },
  'DAYOFMONTH': (a) {
    final dt = _resolveDateTime(a);
    return dt?.day;
  },
  'HOUR': (a) {
    final dt = _resolveDateTime(a);
    return dt?.hour;
  },
  'MINUTE': (a) {
    final dt = _resolveDateTime(a);
    return dt?.minute;
  },
  'SECOND': (a) {
    final dt = _resolveDateTime(a);
    return dt?.second;
  },
  'MICROSECOND': (a) {
    final dt = _resolveDateTime(a);
    return dt == null ? null : dt.millisecond * 1000 + dt.microsecond;
  },
  'DAYOFWEEK': (a) {
    // MySQL: 1=Sunday..7=Saturday.
    final dt = _resolveDateTime(a);
    if (dt == null) return null;
    return (dt.weekday % 7) + 1;
  },
  'WEEKDAY': (a) {
    // MySQL: 0=Monday..6=Sunday.
    final dt = _resolveDateTime(a);
    if (dt == null) return null;
    return dt.weekday - 1;
  },
  'DAYOFYEAR': (a) {
    final dt = _resolveDateTime(a);
    return dt == null ? null : _dayOfYear(dt);
  },
  'DAYNAME': (a) {
    final dt = _resolveDateTime(a);
    if (dt == null) return null;
    const names = [
      'Monday',
      'Tuesday',
      'Wednesday',
      'Thursday',
      'Friday',
      'Saturday',
      'Sunday',
    ];
    return names[dt.weekday - 1];
  },
  'MONTHNAME': (a) {
    final dt = _resolveDateTime(a);
    if (dt == null) return null;
    const names = [
      'January',
      'February',
      'March',
      'April',
      'May',
      'June',
      'July',
      'August',
      'September',
      'October',
      'November',
      'December',
    ];
    return names[dt.month - 1];
  },
  'LAST_DAY': (a) {
    final dt = _resolveDateTime(a);
    if (dt == null) return null;
    final nextMonth = dt.month == 12
        ? DateTime.utc(dt.year + 1, 1, 1)
        : DateTime.utc(dt.year, dt.month + 1, 1);
    final last = nextMonth.subtract(const Duration(days: 1));
    return _fmtDate(last);
  },
  'DATEDIFF': (a) {
    if (a.length < 2) return null;
    final da = _resolveDateTime([a[0]]);
    final db = _resolveDateTime([a[1]]);
    if (da == null || db == null) return null;
    final aMid = DateTime.utc(da.year, da.month, da.day);
    final bMid = DateTime.utc(db.year, db.month, db.day);
    return aMid.difference(bMid).inDays;
  },
  'DATE_FORMAT': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return null;
    final dt = _resolveDateTime([a[0]]);
    if (dt == null) return null;
    return _mysqlDateFormat(a[1].toString(), dt);
  },
  'TIME_FORMAT': (a) {
    if (a.length < 2 || a[0] == null || a[1] == null) return null;
    final dt = _resolveDateTime([a[0]]);
    if (dt == null) return null;
    return _mysqlDateFormat(a[1].toString(), dt);
  },
  'STR_TO_DATE': (a) {
    if (a.length < 2 || a[0] == null) return null;
    // We do not implement a full MySQL parser; defer to ISO parsing of
    // the input string and ignore the format. Good enough for typical
    // 'YYYY-MM-DD' / 'YYYY-MM-DD HH:MM:SS' inputs.
    return _resolveDateTime([a[0]]) == null
        ? null
        : _fmtDateTime(_resolveDateTime([a[0]])!, full: true);
  },
  // ---- Vector / embedding functions (FAISS-style) -----------------------
  // Constructor: TEXT '[1,2,3]' or BLOB / List -> canonical vector BLOB.
  'VEC': (a) {
    if (a.isEmpty || a[0] == null) return null;
    final v = coerceVector(a[0]);
    return v == null ? null : encodeVectorBlob(v);
  },
  'VEC_F32': (a) => kScalarFunctions['VEC']!(a),
  // VEC_DIM(v) -> INTEGER dimension.
  'VEC_DIM': (a) {
    if (a.isEmpty || a[0] == null) return null;
    return coerceVector(a[0])!.dim;
  },
  // V32 sqlite-vec compat: vec_length returns BYTES, not dimensions.
  // Matches sqlite-vec: `vec_length(v) = dim * 4` for float32 vectors.
  'VEC_LENGTH': (a) {
    if (a.isEmpty || a[0] == null) return null;
    return coerceVector(a[0])!.dim * 4;
  },
  // V32 sqlite-vec compat: vec_type always returns 'float32' — the
  // only vector element type we support.
  'VEC_TYPE': (a) {
    if (a.isEmpty || a[0] == null) return null;
    return 'float32';
  },
  // V32 sqlite-vec compat: vec_slice(v, start, end) returns a new
  // vector containing elements [start, end). Negative or out-of-range
  // indices are clamped.
  'VEC_SLICE': (a) => _propagateNull(a, () {
        final v = coerceVector(a[0])!;
        final start = (a[1] as num).toInt().clamp(0, v.dim);
        final end = (a[2] as num).toInt().clamp(start, v.dim);
        return encodeVectorBlob(
          Vector.fromList(v.values.sublist(start, end)),
        );
      }),
  // VEC_TO_JSON(v) -> TEXT '[...]' for debug / export.
  'VEC_TO_JSON': (a) {
    if (a.isEmpty || a[0] == null) return null;
    return coerceVector(a[0])!.toString();
  },
  // Distance metrics.
  'VEC_L2SQ': (a) => _propagateNull(
        a,
        () => vecL2Sq(coerceVector(a[0])!, coerceVector(a[1])!),
      ),
  'VEC_L2': (a) => _propagateNull(
        a,
        () => vecL2(coerceVector(a[0])!, coerceVector(a[1])!),
      ),
  'VEC_DISTANCE_L2': (a) => kScalarFunctions['VEC_L2']!(a),
  'VEC_IP': (a) => _propagateNull(
        a,
        () => vecInnerProduct(coerceVector(a[0])!, coerceVector(a[1])!),
      ),
  'VEC_DOT': (a) => kScalarFunctions['VEC_IP']!(a),
  'VEC_COSINE': (a) => _propagateNull(
        a,
        () => vecCosineDistance(coerceVector(a[0])!, coerceVector(a[1])!),
      ),
  'VEC_DISTANCE_COSINE': (a) => kScalarFunctions['VEC_COSINE']!(a),
  'VEC_COSINE_SIM': (a) => _propagateNull(
        a,
        () => vecCosineSimilarity(coerceVector(a[0])!, coerceVector(a[1])!),
      ),
  // Norm and normalization.
  'VEC_NORM': (a) {
    if (a.isEmpty || a[0] == null) return null;
    return vecNorm(coerceVector(a[0])!);
  },
  'VEC_NORMALIZE': (a) {
    if (a.isEmpty || a[0] == null) return null;
    return encodeVectorBlob(vecNormalize(coerceVector(a[0])!));
  },
  // Element-wise arithmetic (useful for centroid math and tests).
  'VEC_ADD': (a) => _propagateNull(
        a,
        () =>
            encodeVectorBlob(vecAdd(coerceVector(a[0])!, coerceVector(a[1])!)),
      ),
  'VEC_SUB': (a) => _propagateNull(
        a,
        () =>
            encodeVectorBlob(vecSub(coerceVector(a[0])!, coerceVector(a[1])!)),
      ),
  // ---- Hybrid retrieval / rank fusion ----------------------------------
  // RRF_SCORE(rank[, k]) — one term of Reciprocal Rank Fusion. Returns
  // `1 / (k + rank)` for a non-NULL rank, or 0 when the row didn't
  // appear in the ranker's list. `k` defaults to 60 (the FAISS /
  // Cormack & Buettcher canonical value).
  'RRF_SCORE': (a) {
    if (a.isEmpty || a[0] == null) return 0.0;
    final rank = (a[0] as num).toDouble();
    final k = a.length >= 2 && a[1] != null ? (a[1] as num).toDouble() : 60.0;
    return 1.0 / (k + rank);
  },
  // RRF(rank1, rank2, ...) — sum of `1 / (60 + rank_i)` over non-NULL
  // ranks. Missing / NULL ranks contribute 0. Convenience for the
  // canonical two- or three-way fusion `RRF(fts_rank, vec_rank)`.
  'RRF': (a) {
    var s = 0.0;
    for (final v in a) {
      if (v == null) continue;
      s += 1.0 / (60.0 + (v as num).toDouble());
    }
    return s;
  },
  // HYBRID_SCORE(vec_distance, fts_score, alpha) — convex-combination
  // hybrid score. Higher = more relevant.
  //   result = alpha * (1 / (1 + vec_distance)) + (1 - alpha) * fts_score
  // Vector distance is converted to a similarity in [0, 1] via
  // `1 / (1 + d)` (works for L2, L2SQ, and cosine distance). NULLs on
  // either side contribute 0 for that term. `alpha` in [0, 1] controls
  // the mix (0 = FTS only, 1 = vector only).
  'HYBRID_SCORE': (a) {
    if (a.length < 3) return null;
    final vec = a[0];
    final fts = a[1];
    final alpha = a[2] == null ? 0.5 : (a[2] as num).toDouble();
    final vecSim = vec == null ? 0.0 : 1.0 / (1.0 + (vec as num).toDouble());
    final ftsScore = fts == null ? 0.0 : (fts as num).toDouble();
    return alpha * vecSim + (1.0 - alpha) * ftsScore;
  },
};