// The value model shared by every MoonLoom package.
//
// MoonLoom reads semi-structured text, so the type of a field is decided while
// reading rather than declared up front. `Int` keeps whole numbers exact,
// `Num` holds fractional numbers, `Str` keeps every other payload verbatim, and
// `Null` marks a field that was absent or empty.
///|
/// One field value.
pub(all) enum Value {
Null
Bool(Bool)
Int(Int64)
Num(Double)
Str(String)
} derive(Eq, Debug)
///|
pub extend Value with Eq::{not_equal, equal}
///|
pub extend Value with @debug.Debug::{to_repr}
///|
/// The coarse type of a value, used by schema inference and by the renderer.
/// `KindMixed` is never produced by a single value; inference reports it for a
/// column whose rows disagree.
pub(all) enum ValueKind {
KindNull
KindBool
KindInt
KindNum
KindText
KindMixed
} derive(Eq, Debug)
///|
pub extend ValueKind with Eq::{not_equal, equal}
///|
pub extend ValueKind with @debug.Debug::{to_repr}
///|
/// Stable label of a kind, as printed by `moonloom schema`.
pub fn ValueKind::label(self : ValueKind) -> String {
match self {
KindNull => "null"
KindBool => "bool"
KindInt => "int"
KindNum => "num"
KindText => "text"
KindMixed => "mixed"
}
}
///|
/// The kind of a value.
pub fn Value::kind(self : Value) -> ValueKind {
match self {
Null => KindNull
Bool(_) => KindBool
Int(_) => KindInt
Num(_) => KindNum
Str(_) => KindText
}
}
///|
pub fn Value::is_null(self : Value) -> Bool {
match self {
Null => true
_ => false
}
}
///|
/// User-facing text of a value. Strings are printed verbatim, without quotes;
/// use `Value::json_text` when the output has to stay machine readable.
pub fn Value::display(self : Value) -> String {
match self {
Null => ""
Bool(b) => if b { "true" } else { "false" }
Int(i) => i.to_string()
Num(d) => format_double(d)
Str(s) => s
}
}
///|
/// The text of a value as it would appear inside a JSON document.
pub fn Value::json_text(self : Value) -> String {
match self {
Null => "null"
Bool(b) => if b { "true" } else { "false" }
Int(i) => i.to_string()
Num(d) => format_double(d)
Str(s) => "\"" + escape_json(s) + "\""
}
}
///|
/// Numeric view of a value: integers are widened, booleans become 0 or 1, and
/// everything else has no numeric view.
pub fn Value::as_num(self : Value) -> Double? {
match self {
Int(i) => Some(i.to_double())
Num(d) => Some(d)
Bool(b) => Some(if b { 1.0 } else { 0.0 })
_ => None
}
}
///|
/// Integral view of a value. Fractional values are only accepted when they have
/// no fractional part, so `1.0` converts while `1.5` does not.
pub fn Value::as_int(self : Value) -> Int64? {
match self {
Int(i) => Some(i)
Num(d) => {
let t = d.to_int64()
if t.to_double() == d {
Some(t)
} else {
None
}
}
_ => None
}
}
///|
/// Text view of a value, used by string functions and by grouping.
pub fn Value::as_text(self : Value) -> String {
self.display()
}
///|
/// Truth value used by `where` and by boolean functions. Missing values are
/// false, numbers are true when non-zero, and non-empty strings are true.
pub fn Value::truthy(self : Value) -> Bool {
match self {
Null => false
Bool(b) => b
Int(i) => i != 0L
Num(d) => d != 0.0
Str(s) => s != ""
}
}
///|
fn Value::value_rank(self : Value) -> Int {
match self {
Null => 0
Bool(_) => 1
Int(_) | Num(_) => 2
Str(_) => 3
}
}
///|
fn compare_double(a : Double, b : Double) -> Int {
if a.is_nan() && b.is_nan() {
0
} else if a.is_nan() {
-1
} else if b.is_nan() {
1
} else if a < b {
-1
} else if a > b {
1
} else {
0
}
}
///|
/// Total order over values, used for sorting and for range predicates:
/// missing first, then booleans, then every number together, then text.
pub fn Value::compare(self : Value, other : Value) -> Int {
let ra = self.value_rank()
let rb = other.value_rank()
if ra != rb {
return if ra < rb { -1 } else { 1 }
}
match (self, other) {
(Null, Null) => 0
(Bool(a), Bool(b)) => if a == b { 0 } else if a { 1 } else { -1 }
(Int(a), Int(b)) => if a == b { 0 } else if a < b { -1 } else { 1 }
(Int(a), Num(b)) => compare_double(a.to_double(), b)
(Num(a), Int(b)) => compare_double(a, b.to_double())
(Num(a), Num(b)) => compare_double(a, b)
(Str(a), Str(b)) => @ascii.compare_text(a, b)
_ => 0
}
}
///|
/// True when two values carry the same text, ignoring their kinds. `1` and
/// `"1"` agree here, which is what a text filter over a log expects.
pub fn Value::text_eq(self : Value, other : Value) -> Bool {
self.display() == other.display()
}
///|
fn looks_like_integer(text : StringView) -> Bool {
let cu = text.code_units()
let n = cu.length()
if n == 0 {
return false
}
let mut index = 0
if @ascii.ascii_is_sign(cu[0]) {
index = 1
}
if index >= n {
return false
}
while index < n {
if !@ascii.ascii_is_digit(cu[index]) {
return false
}
index += 1
}
true
}
///|
fn looks_like_number(text : StringView) -> Bool {
let cu = text.code_units()
let n = cu.length()
if n == 0 {
return false
}
let mut digits = 0
let mut dots = 0
let mut exponent = false
let mut index = 0
if @ascii.ascii_is_sign(cu[0]) {
index = 1
}
while index < n {
let c = cu[index]
if @ascii.ascii_is_digit(c) {
digits += 1
} else if @ascii.code_unit_is(c, '.') {
dots += 1
} else if @ascii.code_unit_is(c, 'e') || @ascii.code_unit_is(c, 'E') {
if digits == 0 {
return false
}
exponent = true
index += 1
let tail = text[index:]
return digits > 0 && looks_like_integer(tail)
} else {
return false
}
index += 1
}
dots <= 1 && digits > 0 && (dots == 1 || exponent)
}
///|
/// Schema-on-read typing of one text field.
///
/// The result only claims what the text itself proves: an integer is only
/// reported when the digits round-trip, so `"007"` stays text instead of
/// silently losing its leading zeros, and a value only becomes a number when it
/// really carries a fraction or an exponent.
pub fn Value::from_text(text : String) -> Value {
let t = text.trim()
if t.length() == 0 {
return Null
}
let lower = t.to_lower()
if lower == "true" {
return Bool(true)
}
if lower == "false" {
return Bool(false)
}
if lower == "null" {
return Null
}
if looks_like_integer(t) {
try {
let i = @string.parse_int64(t)
if i.to_string() == t.to_owned() {
return Int(i)
}
} catch {
_ => ()
}
}
if looks_like_number(t) {
try {
let d = @string.parse_double(t)
if !d.is_nan() && !d.is_inf() {
return Num(d)
}
} catch {
_ => ()
}
}
Str(t.to_owned())
}
///|
/// Renders a double in a compact, stable form: whole numbers lose the `.0`
/// suffix so that a count of `3` prints as `3` rather than `3.0`, while
/// fractional values keep their digits.
pub fn format_double(d : Double) -> String {
if d.is_nan() {
return "NaN"
}
if d.is_inf() {
return if d > 0.0 { "inf" } else { "-inf" }
}
if d == d.to_int64().to_double() && d.abs() < 1.0e15 {
d.to_int64().to_string()
} else {
d.to_string()
}
}
///|
/// Escapes a string for embedding inside a JSON document.
pub fn escape_json(s : String) -> String {
let out = StringBuilder()
for c in s {
match c {
'"' => out.write_string("\\\"")
'\\' => out.write_string("\\\\")
'\n' => out.write_string("\\n")
'\r' => out.write_string("\\r")
'\t' => out.write_string("\\t")
'\u{08}' => out.write_string("\\b")
'\u{0C}' => out.write_string("\\f")
_ => {
let code = c.to_int()
if code < 0x20 {
out.write_string("\\u")
let hex = code.to_string(radix=16)
let mut pad = 4 - hex.length()
while pad > 0 {
out.write_string("0")
pad -= 1
}
out.write_string(hex)
} else {
out.write_char(c)
}
}
}
}
out.to_string()
}