From a35b0ec54bd6b3bb8e0c7e714f062a54cc4a85b4 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:45:37 +0700 Subject: [PATCH] Store a decimal column as unscaled units on the scalar lane A decimal value has been exact since the last change; a decimal column has not existed. This adds it, for the precisions a lane word holds. The lane stores the unscaled integer and the declared type stores the scale, so a column of DECIMAL(12,2) is a column of pence and the type is what makes a word of them into one pound twenty. That is the declared versus encoding split of schema/06 section 2 at its plainest, and it is the first column type whose declaration is needed to read a row back rather than only to check one. Where the frontier falls follows from the lane rather than from a number picked here. LogicalType::physical already maps a decimal by IntBits::for_digits, so eighteen digits of unscaled units ride a 64 bit word and nineteen do not. DECIMAL(18,2) is storable and DECIMAL(38,2) stays declarable in the grammar and refused by the catalog, which is the same shape INT128 and FLOAT16 have on the S2 list. A value the column cannot hold exactly at its own scale is refused with 22003 rather than rounded. Rounding a price on the way into a ledger is the mistake this type exists to stop, and a caller who wants it rounded has ROUND to ask with. A value wider than the declared precision is refused the same way, on the statement path and again at the column writer, which is where a bulk caller meets it. Two hazards are closed on purpose. A bounded list of decimals would have passed the lane width test and produced a file whose elements read back as integers of units, so bounded_list refuses a decimal element. The vector read path has no decimal arm, so check_col refuses a decimal column there rather than handing back unscaled units dressed as integers. The declared form and the column form stay one encoding: the lane bound lives in extended_bytes, which both go through, so a decimal a graph type may name is a decimal a column can hold and there is no gap between them for a declaration to fall into. Left open: decimals as list elements, the bulk append path, the vector path, and precisions above eighteen digits, which want a plane of their own the way a zoned column has one. --- conformance/cases/error.yaml | 16 +- crates/zu-zu1/src/catalog.rs | 9 +- crates/zu-zu1/src/props.rs | 146 ++++++++++++++++-- crates/zu/src/declare.rs | 71 +++++++++ crates/zu/src/insert.rs | 32 +++- crates/zu/src/query.rs | 10 +- crates/zu/src/session.rs | 9 +- crates/zu/src/snapshot.rs | 17 +- crates/zu/tests/catalog_statements.rs | 2 +- crates/zu/tests/graph_type.rs | 54 ++++--- crates/zu/tests/refusal_shape.rs | 2 +- crates/zu/tests/refusals.rs | 2 +- .../tests/snapshots/declaration-refusals.txt | 2 +- 13 files changed, 318 insertions(+), 54 deletions(-) diff --git a/conformance/cases/error.yaml b/conformance/cases/error.yaml index d0227a71..58966065 100644 --- a/conformance/cases/error.yaml +++ b/conformance/cases/error.yaml @@ -197,10 +197,22 @@ cases: raises: "42000" - name: a-property-type-no-column-can-hold - doc: A declared type is written into the catalog with the codes a column stores, so a type no column has is a type no element type can name. DECIMAL parses and has no column here, and the refusal is about the statement rather than about the file, which is why it is 42000 and not a report of damage. - query: "CREATE GRAPH TYPE t { (:P {v :: DECIMAL(12,2)}) }" + doc: A declared type is written into the catalog with the codes a column stores, so a type no column has is a type no element type can name. A decimal column holds unscaled units in a 64 bit word and the declared scale is what makes them a number, so thirty eight digits of them are more than a column here has room for. The refusal is about the statement rather than about the file, which is why it is 42000 and not a report of damage. + query: "CREATE GRAPH TYPE t { (:P {v :: DECIMAL(38,2)}) }" raises: "42000" + - name: a-decimal-column-is-a-declaration-a-graph-type-takes + doc: The other side of the line above. A decimal whose unscaled units fit a 64 bit word is a column, so the declaration stands, and eighteen digits is the widest that does. The declaration is the setup because a catalog statement returns no rows, so what the case asserts is that it did not raise. + setup: + - "CREATE GRAPH TYPE ledger { (:Purchase {total :: DECIMAL(18,2)}) }" + query: RETURN 18 AS digits + columns: + - digits + rows: + - values: + - type: INT64 + value: "18" + - name: an-embedding-column-is-a-declaration-a-graph-type-takes doc: A property declared LIST[768] is the embedding column, and a graph type takes it. The declaration is the setup here because a catalog statement returns no rows, so what the case asserts is that it did not raise. What the catalog keeps is the bound and the element's nullability, which are what every later check about the column is made against, and a catalog that took the declaration and forgot either of them would be worse than one that refused it. setup: diff --git a/crates/zu-zu1/src/catalog.rs b/crates/zu-zu1/src/catalog.rs index f48d96da..2bedd85d 100644 --- a/crates/zu-zu1/src/catalog.rs +++ b/crates/zu-zu1/src/catalog.rs @@ -2126,19 +2126,22 @@ mod tests { )) .expect("a list of lists is declarable"); // A type the declared form has no shape for at all is still - // refused where it is written and not where it is encoded. + // refused where it is written and not where it is encoded. A + // narrow decimal is no longer one of those, since its unscaled + // units ride a lane word, so the example is one whose units do + // not: thirty eight digits want more than sixty four bits. let err = c .add_graph_type(GraphType::open("money").with( ElementType::node("Purchase", vec![person]).with_property( "total", LogicalType::Decimal { - precision: 12, + precision: 38, scale: 2, }, true, ), )) - .expect_err("a decimal has no declared form") + .expect_err("a wide decimal has no declared form") .to_string(); assert!(err.contains("a type this file cannot write"), "{err}"); } diff --git a/crates/zu-zu1/src/props.rs b/crates/zu-zu1/src/props.rs index 3cd6542a..a7bac740 100644 --- a/crates/zu-zu1/src/props.rs +++ b/crates/zu-zu1/src/props.rs @@ -32,7 +32,8 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; use zu_common::{ - DurationKind, FloatBits, IdMap, IntBits, LogicalType, PhysicalType, Result, ZuError, int_key, + Decimal, DurationKind, FloatBits, IdMap, IntBits, LogicalType, PhysicalType, Result, ZuError, + int_key, }; use crate::catalog::{Catalog, TableIndex}; @@ -68,8 +69,12 @@ use crate::txn::Cell; /// adds the element count a list column's rows are written at, which is /// what lets the rows stop carrying one each. Version 10 gives a column /// entry a second segment, for the zone plane of a zoned column, which -/// is the first column here that is not one segment of values. -const PROPS_VERSION: u16 = 10; +/// is the first column here that is not one segment of values. Version +/// 11 adds the exact decimal to the extended form, which is the first +/// column type whose declaration is needed to read a row back: the lane +/// holds unscaled units and the scale that says how large a unit is +/// lives in the type. +const PROPS_VERSION: u16 = 11; const MAX_NAME_LEN: usize = 256; /// The code a list column is written under, followed on disk by the @@ -223,10 +228,15 @@ fn type_bytes(ty: &LogicalType) -> Option> { /// /// A length bound is the declaration a column takes this way, on a /// string, a byte string or a list, and a zoned temporal takes it with -/// nothing beside it, since the type is the whole of what it says. What -/// is left unstorable is a list of anything but a fixed width element, -/// whose row has to carry lengths of its own; saying so here is what -/// keeps the writer from making a file the reader below would refuse. +/// nothing beside it, since the type is the whole of what it says. A +/// decimal takes its precision and its scale, and `extended_bytes` is +/// where the precisions a lane word holds are told from the ones it does +/// not, because that answer has to be the same for the catalog. +/// +/// What is left unstorable is a list of anything but a fixed width +/// element, whose row has to carry lengths of its own; saying so here is +/// what keeps the writer from making a file the reader below would +/// refuse. fn column_type_bytes(ty: &LogicalType) -> Option> { match ty { _ if bounded_list(ty).is_some() => extended_bytes(&column_type(ty.clone())), @@ -234,7 +244,8 @@ fn column_type_bytes(ty: &LogicalType) -> Option> { LogicalType::Str { .. } | LogicalType::Bytes { .. } | LogicalType::ZonedTime - | LogicalType::ZonedDatetime => extended_bytes(ty), + | LogicalType::ZonedDatetime + | LogicalType::Decimal { .. } => extended_bytes(ty), _ => None, } } @@ -281,7 +292,16 @@ pub(crate) fn bounded_list(ty: &LogicalType) -> Option<(u32, usize)> { elem, max: Some(max), } if *max > 0 => { - let (width, _) = lane_width(list_elem(elem)?)?; + let inner = list_elem(elem)?; + // A decimal rides the lane, so this would otherwise say + // yes, and the element reader has no decimal arm: a row + // would go in as digits and come back as an integer of + // them. Refusing here is what keeps the writer from making + // a file the reader misreads rather than refuses. + if matches!(inner, LogicalType::Decimal { .. }) { + return None; + } + let (width, _) = lane_width(inner)?; Some((*max, width)) } _ => None, @@ -355,6 +375,10 @@ const EXT_BYTES: u8 = 3; /// element's own nullability, the maximum length, and the element type /// written out in full so that a list of lists has somewhere to go. const EXT_LIST: u8 = 4; +/// An exact decimal, as its precision and its scale. Both are needed to +/// read a row: the lane holds unscaled units and the scale is what says +/// a unit is a hundredth. +const EXT_DECIMAL: u8 = 5; /// The count that stands for a bound nobody wrote, since a length is a /// `u32` and every one of them is a length somebody could write. @@ -390,6 +414,21 @@ fn extended_bytes(ty: &LogicalType) -> Option> { Some(match ty { LogicalType::ZonedTime => vec![EXTENDED_CODE, EXT_ZONED_TIME], LogicalType::ZonedDatetime => vec![EXTENDED_CODE, EXT_ZONED_DATETIME], + // A decimal is written where its unscaled units fit the lane, + // and refused where they do not. The bound belongs here rather + // than one caller up because the declared form and the column + // form are one encoding for this type: a precision no column can + // hold is a precision no element type can name, and the refusal + // then lands at the declaration, where the user wrote it, rather + // than at the first insert. Wider precisions want a plane of + // their own the way a zoned column has one, and that is S2's. + LogicalType::Decimal { precision, scale } => { + lane_width(ty)?; + let mut out = vec![EXTENDED_CODE, EXT_DECIMAL]; + out.extend(precision.to_le_bytes()); + out.extend(scale.to_le_bytes()); + out + } LogicalType::Str { min, max, fixed } => bounded(EXT_STR, min, max, *fixed), LogicalType::Bytes { min, max, fixed } => bounded(EXT_BYTES, min, max, *fixed), // The element is written by the same function, so a list of @@ -447,6 +486,20 @@ fn decode_extended_type(bytes: &[u8], pos: &mut usize) -> Result { if kind == EXT_ZONED_DATETIME { return Ok(LogicalType::ZonedDatetime); } + if kind == EXT_DECIMAL { + let mut digits = || -> Result { + let end = *pos + 2; + let word: [u8; 2] = bytes + .get(*pos..end) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| corrupt("truncated decimal digits".into()))?; + *pos = end; + Ok(u16::from_le_bytes(word)) + }; + let precision = digits()?; + let scale = digits()?; + return Ok(LogicalType::Decimal { precision, scale }); + } fn count(bytes: &[u8], pos: &mut usize) -> Result> { let end = *pos + 4; let word: [u8; 4] = bytes @@ -980,7 +1033,12 @@ impl PropValues<'_> { pub fn none_of(ty: &LogicalType) -> Option> { Some(match ty { LogicalType::Bool => PropValues::Bool(&[]), - LogicalType::Int { .. } => PropValues::Int(&[]), + // A decimal column is a lane of unscaled units, so what an + // empty one holds none of is integers. The scale that makes + // them a number is in the declared type beside them, which + // is why this can be the same empty lane an integer column + // gets without the two columns being the same column. + LogicalType::Int { .. } | LogicalType::Decimal { .. } => PropValues::Int(&[]), LogicalType::Float { .. } => PropValues::Float(&[]), LogicalType::Str { .. } => PropValues::Str(&[]), LogicalType::Bytes { .. } => PropValues::Bytes(&[]), @@ -1837,6 +1895,30 @@ fn check_declared( } return Ok(()); } + // A decimal is the integer arm with the promise read the other + // way. The lane holds unscaled units and the declaration says + // how many digits of them a value of this column has, so what + // is checked is the digit count rather than a width: `1.20` in + // a `DECIMAL(12,2)` is the word 120 and three digits of the + // twelve, and a word needing thirteen is a row the column's own + // type says is not there. The scale is not checked because the + // lane cannot disagree with it: a unit is whatever the declared + // scale says a unit is. + (LogicalType::Decimal { precision, scale }, PropValues::Int(words)) => { + for (row, &word) in words.iter().enumerate() { + if !column.holds(row) { + continue; + } + let held = Decimal::new(i128::from(word as i64), *scale); + if held.digits() <= *precision { + continue; + } + return Err(ZuError::InvalidArgument(format!( + "column '{name}' is declared {ty} and row {row} holds {held}" + ))); + } + return Ok(()); + } // A float is the other way round: the lane holds IEEE bits, and // an `f32`'s are not a half of an `f64`'s, so a narrower // declaration is a different word and not a promise about the @@ -5087,7 +5169,14 @@ mod tests { ) .unwrap(); let mut bytes = directory.encode(); - assert_eq!(u16::from_le_bytes(bytes[..2].try_into().unwrap()), 10); + // The first two bytes are the version, whatever it has reached. + // What this test is about is the one below it, so it reads the + // current one rather than naming a number that has to be + // corrected every time the format gains a column type. + assert_eq!( + u16::from_le_bytes(bytes[..2].try_into().unwrap()), + PROPS_VERSION + ); bytes[..2].copy_from_slice(&9u16.to_le_bytes()); let err = PropsDirectory::decode(&bytes).unwrap_err(); assert!(err.to_string().contains("version 9 directory"), "{err}"); @@ -5563,6 +5652,28 @@ mod tests { max: Some(3), }; assert_eq!(column_type_bytes(&fixed), declared_type_bytes(&fixed)); + // A decimal is the first type whose storability turns on an + // argument rather than on the type. It rides the lane as a whole + // number of unscaled units, so the question is whether the units + // fit a lane word, and eighteen digits is the last precision + // that does. Both forms answer alike, so a decimal a graph type + // names is a decimal a column holds and there is no gap between + // them for a declaration to fall into. + let decimal = |precision| LogicalType::Decimal { + precision, + scale: 2, + }; + for precision in [1, 9, 18] { + let ty = decimal(precision); + assert_eq!(column_type_bytes(&ty), declared_type_bytes(&ty), "{ty}"); + assert!(storable(&ty), "{ty}"); + } + for precision in [19, 38] { + let ty = decimal(precision); + assert!(declared_type_bytes(&ty).is_none(), "{ty}"); + assert!(column_type_bytes(&ty).is_none(), "{ty}"); + assert!(!storable(&ty), "{ty}"); + } } #[test] @@ -5744,11 +5855,22 @@ mod tests { ), (bounded_list(LogicalType::float(FloatBits::B32), 768), true), (list_of(list_of(LogicalType::string())), true), + // A decimal whose unscaled units fit the lane. This is the + // one row of the table whose sibling is on the other side of + // it: the type is storable and an argument to it is what + // decides, which no other row here can say. + ( + LogicalType::Decimal { + precision: 12, + scale: 2, + }, + true, + ), // Declarable and not storable. Each of these is an entry in // schema/06 section 6, and S2 turns them true one at a time. ( LogicalType::Decimal { - precision: 12, + precision: 38, scale: 2, }, false, diff --git a/crates/zu/src/declare.rs b/crates/zu/src/declare.rs index a7986505..b86468cc 100644 --- a/crates/zu/src/declare.rs +++ b/crates/zu/src/declare.rs @@ -1206,6 +1206,77 @@ mod tests { assert!(catalog.node_in(graph, "event").is_none()); } + /// A decimal column is the first one whose declaration is part of + /// reading it back. The lane holds a whole number of units and the + /// scale in the declared type says how large a unit is, so a column + /// of `DECIMAL(12,2)` is a column of pence and the type is what + /// turns a hundred and twenty of them into 1.20. + /// + /// The scale binds on the way in as well as on the way out. A value + /// the column cannot hold exactly is refused rather than rounded: + /// rounding a price on the way into a ledger is the mistake this + /// type exists to stop, and a caller who wants it rounded has + /// `ROUND` to ask with. + #[test] + fn a_declared_decimal_column_keeps_its_scale_in_and_out() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("decimal.zu1"); + seeded(&path); + let mut session = Session::open(&path).expect("open"); + for stmt in [ + "CREATE PROPERTY GRAPH TYPE t { (:purchase {total :: DECIMAL(12,2)}) }", + "CREATE GRAPH g TYPED t", + // One at the column's own scale, one coarser, and a plain + // integer. All three are exact at two places, so all three + // are values this column holds. + "USE g INSERT (p:purchase {total: CAST('1.20' AS DECIMAL(5,2))})", + "USE g INSERT (p:purchase {total: CAST('0.5' AS DECIMAL(5,1))})", + "USE g INSERT (p:purchase {total: 7})", + ] { + session + .run(stmt, &[]) + .expect("the graph, its type, its rows"); + } + + // The column stores 120, 50 and 700 units and the declared scale + // is what turns them back into the numbers written. The second + // place on 0.50 is the column's rather than the literal's, which + // is the whole point of keeping the scale in the catalog. + let rows = session + .run( + "USE g MATCH (p:purchase) RETURN p.total AS total ORDER BY total", + &[], + ) + .expect("the rows read back"); + let read: Vec = rows + .rows + .iter() + .map(|row| match &row[0] { + Value::Decimal(d) => d.to_string(), + other => panic!("expected an exact decimal, got {other:?}"), + }) + .collect(); + assert_eq!(read, ["0.50", "1.20", "7.00"]); + + // A third place is not something this column holds, and 22003 is + // the condition for a value outside a numeric type's range. + let err = session + .run( + "USE g INSERT (p:purchase {total: CAST('1.234' AS DECIMAL(6,3))})", + &[], + ) + .expect_err("the column has two places and this has three"); + assert_eq!(err.gqlstatus().map(|s| s.code()), Some("22003")); + + // So is a number wider than the declared precision, which is the + // other half of what `DECIMAL(12,2)` promised: eleven digits and + // two places is thirteen, and twelve is the whole of it. + let err = session + .run("USE g INSERT (p:purchase {total: 12345678901})", &[]) + .expect_err("thirteen digits do not fit twelve"); + assert_eq!(err.gqlstatus().map(|s| s.code()), Some("22003")); + } + /// A refusal is asked once whether this module can do anything for /// it, and the answer is held with it, so the second send of the /// same bad statement does not parse it again to find out. What diff --git a/crates/zu/src/insert.rs b/crates/zu/src/insert.rs index d41f9f59..a35577ff 100644 --- a/crates/zu/src/insert.rs +++ b/crates/zu/src/insert.rs @@ -22,7 +22,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use zu_common::gqlstatus::{Subject, codes}; -use zu_common::{FloatBits, GqlStatus, LogicalType, Result, Temporal, ZuError}; +use zu_common::{Decimal, FloatBits, GqlStatus, LogicalType, Result, Temporal, ZuError}; use zu_query::binder::{BoundExpr, BoundInsertNode, BoundInsertRel}; use crate::deleted::Deleted; @@ -717,6 +717,36 @@ pub(crate) fn cell(ty: &LogicalType, value: &Value, key: &str) -> Result { _ => Cell::Int(f.to_bits()), } } + // A decimal column is a column of unscaled units, so a value + // goes in written at the column's scale. A value the column + // cannot write there exactly is refused rather than rounded: + // rounding a price on the way into a ledger is the mistake this + // whole type exists to stop, and a caller who wants two places + // out of three digits says so with a cast. + // + // An integer is an exact number of scale nought, so it goes in + // the same way a whole number goes into a float column. + (LogicalType::Decimal { precision, scale }, Value::Decimal(_) | Value::Int(_)) => { + let given = match value { + Value::Decimal(d) => *d, + Value::Int(n) => Decimal::new(i128::from(*n), 0), + _ => unreachable!("matched just above"), + }; + let at = given + .rescale(*scale) + .filter(|at| at.digits() <= *precision) + .and_then(|at| i64::try_from(at.unscaled()).ok()); + match at { + Some(units) => Cell::Int(units as u64), + None => { + return Err(ZuError::gql( + codes::C22003, + format!("property '{key}' holds {ty}, and {given} is not a value of it"), + ) + .about(Subject::Property(key.to_string()))); + } + } + } // A length bound counts two different things on the two sides. // A character is a Unicode scalar value and an octet is a byte, // so `STRING(5,5)` admits five astral characters in twenty diff --git a/crates/zu/src/query.rs b/crates/zu/src/query.rs index ca768de1..597aea22 100644 --- a/crates/zu/src/query.rs +++ b/crates/zu/src/query.rs @@ -24,7 +24,7 @@ use crate::zu1::graph::{Direction, GraphReader}; use crate::zu1::props::{ ListElement, PropsReader, list_elements, load_props, load_props_at, zoned, }; -use zu_common::{FloatBits, LogicalType, Temporal}; +use zu_common::{Decimal, FloatBits, LogicalType, Temporal}; /// The same rows read down their columns, which is the shape every /// client that hands a result to Arrow, pandas or polars needs. @@ -257,6 +257,14 @@ fn word_value(ty: &LogicalType, word: u64, key: &str) -> Result { Ok(match ty { LogicalType::Bool => Value::Bool(word != 0), LogicalType::Int { .. } => Value::Int(word as i64), + // The lane holds unscaled units and the declared type holds the + // scale, which is the declared versus encoding split of + // schema/06 ยง2 at its plainest: a column of DECIMAL(12,2) is a + // column of pence, and the type is what makes a word of them + // into one pound twenty. + LogicalType::Decimal { scale, .. } => { + Value::Decimal(Decimal::new(i128::from(word as i64), *scale)) + } LogicalType::Float { bits, .. } => match bits { FloatBits::B32 => Value::Float(f64::from(f32::from_bits(word as u32))), _ => Value::Float(f64::from_bits(word)), diff --git a/crates/zu/src/session.rs b/crates/zu/src/session.rs index da266a7e..e65f75f1 100644 --- a/crates/zu/src/session.rs +++ b/crates/zu/src/session.rs @@ -4266,12 +4266,13 @@ mod tests { let epoch = session.epoch; let labels = session.graph.catalog().labels().len(); // The catalog writes a property type in a declared form, and a - // decimal has no shape in it. The statement interned `Ghost` on - // the way to finding that out, and the file still has to come - // out unchanged. + // decimal this wide has no shape in it: its unscaled units want + // more than the sixty four bit lane word a narrow one rides. + // The statement interned `Ghost` on the way to finding that out, + // and the file still has to come out unchanged. let err = session .run( - "CREATE GRAPH TYPE strict { (:Ghost {seen :: DECIMAL(12,2)}) }", + "CREATE GRAPH TYPE strict { (:Ghost {seen :: DECIMAL(38,2)}) }", &[], ) .expect_err("a property type no column can hold") diff --git a/crates/zu/src/snapshot.rs b/crates/zu/src/snapshot.rs index 2db96ce2..a2743cd9 100644 --- a/crates/zu/src/snapshot.rs +++ b/crates/zu/src/snapshot.rs @@ -466,14 +466,17 @@ fn check_col(reader: &PropsReader, col: ColId) -> Result { reader.columns().len() ))); } - // A zoned column rides the lane and is not a lane the vector layer - // has: what the words hold is instants, and the offsets that make - // them zoned are a plane a vector has no room for. `vector_col` - // already keeps one out of a plan, and this is the same rule said - // where the read happens, so a caller that reached here another way - // is refused rather than handed instants dressed as integers. + // Two column types ride the lane and are not lanes the vector layer + // has. A zoned column's words are instants, and the offsets that + // make them zoned are a plane a vector has no room for. A decimal + // column's words are unscaled units, and the scale that makes them + // a number is in the declared type, which a vector does not carry. + // `vector_col` already keeps both out of a plan, and this is the + // same rule said where the read happens, so a caller that reached + // here another way is refused rather than handed words dressed as + // integers. let column = &reader.columns()[ix]; - if zoned(&column.ty) { + if zoned(&column.ty) || matches!(column.ty, LogicalType::Decimal { .. }) { return Err(ZuError::InvalidArgument(format!( "column '{}' holds {}, which the vector layer has no lane for", column.name, column.ty diff --git a/crates/zu/tests/catalog_statements.rs b/crates/zu/tests/catalog_statements.rs index d2bdace7..383b1e70 100644 --- a/crates/zu/tests/catalog_statements.rs +++ b/crates/zu/tests/catalog_statements.rs @@ -234,7 +234,7 @@ fn every_element_type_a_graph_type_may_declare_is_performed() { /// A union of two property types is a `value_type` the grammar allows /// and the parser reads, and there is no column that holds either an /// `INT64` or a `STRING`, so the catalog will not write it. The refusal -/// is the same shape `graph_type.rs` pins for `DECIMAL(12,2)`: 42000, +/// is the same shape `graph_type.rs` pins for `DECIMAL(38,2)`: 42000, /// and nothing about a file that is not damaged. S2 decides whether a /// union gets a column at all. #[test] diff --git a/crates/zu/tests/graph_type.rs b/crates/zu/tests/graph_type.rs index 899e7cfd..d2304883 100644 --- a/crates/zu/tests/graph_type.rs +++ b/crates/zu/tests/graph_type.rs @@ -96,25 +96,35 @@ fn probe() -> Vec<(&'static str, Answer)> { // so a declaration is still all they are. ("LIST[768]", Answer::Declared), ("LIST>", Answer::Declared), + // A decimal a 64 bit lane holds. The column stores unscaled + // units and the declared scale is what makes them a number, so + // twelve digits of them fit a word and this is a column a + // statement can fill. + ("DECIMAL(12,2)", Answer::Declared), // Spelled and not stored. This is the list S2 works through. // The condition is 42000 for all of them, which is what S1 // replaced the sentence about corruption with. - ("DECIMAL(12,2)", cannot_write(26)), - ("INT128", cannot_write(27)), - ("INT256", cannot_write(28)), - ("UINT128", cannot_write(29)), - ("FLOAT16", cannot_write(30)), - ("FLOAT128", cannot_write(31)), - ("FLOAT256", cannot_write(32)), - ("ANY", cannot_write(33)), - ("ANY PROPERTY VALUE", cannot_write(34)), - ("PATH", cannot_write(35)), - ("NODE", cannot_write(36)), - ("EDGE", cannot_write(37)), - ("GRAPH", cannot_write(38)), - ("BINDING TABLE", cannot_write(39)), - ("NULL", cannot_write(40)), - ("NOTHING", cannot_write(41)), + // + // The wide decimal is here for the same reason INT128 is: its + // unscaled units want more than a lane word, and the lane is + // sixty four bits. It is the one row on this list whose sibling + // is on the list above. + ("DECIMAL(38,2)", cannot_write(27)), + ("INT128", cannot_write(28)), + ("INT256", cannot_write(29)), + ("UINT128", cannot_write(30)), + ("FLOAT16", cannot_write(31)), + ("FLOAT128", cannot_write(32)), + ("FLOAT256", cannot_write(33)), + ("ANY", cannot_write(34)), + ("ANY PROPERTY VALUE", cannot_write(35)), + ("PATH", cannot_write(36)), + ("NODE", cannot_write(37)), + ("EDGE", cannot_write(38)), + ("GRAPH", cannot_write(39)), + ("BINDING TABLE", cannot_write(40)), + ("NULL", cannot_write(41)), + ("NOTHING", cannot_write(42)), // Not spelled at all. The year month duration is the one type a // column already holds that no declaration can ask for. ("YEAR MONTH DURATION", Answer::NotSpelled), @@ -135,7 +145,7 @@ fn a_graph_type_declares_the_types_the_frontier_says_it_can() { declared += 1; } } - assert_eq!(declared, 25, "the declarable set changed"); + assert_eq!(declared, 26, "the declarable set changed"); } /// The frontier has a far side, and one type is on it. @@ -176,7 +186,7 @@ fn the_year_month_duration_is_stored_and_cannot_be_declared() { /// Every refusal above carries a condition and none of them says /// corrupt, which is what S1 changed. /// -/// A user who writes `DECIMAL(12,2)` has written a legal GQL statement +/// A user who writes `DECIMAL(38,2)` has written a legal GQL statement /// that this engine will not perform. Before S1 they were told their /// file was damaged, with no condition to catch on. Now it is 42000, /// syntax error or access rule violation, which is the class the @@ -184,12 +194,16 @@ fn the_year_month_duration_is_stored_and_cannot_be_declared() { /// two data exception codes that say invalid value type, 22G03 and /// 22G12, are class 22 and are about a value at run time; a type in a /// declaration is not a value. +/// +/// The example is the wide decimal rather than the narrow one because +/// the narrow one now stores. Thirty eight digits of unscaled units do +/// not fit a lane word, so this is where the same sentence still holds. #[test] fn a_type_the_catalog_will_not_write_is_refused_with_a_condition() { let dir = tempfile::tempdir().unwrap(); let mut db = graph(dir.path()); - let source = "CREATE GRAPH TYPE money { (:Purchase {total :: DECIMAL(12,2)}) }"; - let err = run(source, &mut db, &[]).expect_err("a decimal is not storable"); + let source = "CREATE GRAPH TYPE money { (:Purchase {total :: DECIMAL(38,2)}) }"; + let err = run(source, &mut db, &[]).expect_err("a wide decimal is not storable"); assert_eq!(err.gqlstatus().map(|s| s.to_string()), Some("42000".into())); assert!(!err.to_string().contains("corrupt"), "{err}"); assert!( diff --git a/crates/zu/tests/refusal_shape.rs b/crates/zu/tests/refusal_shape.rs index e79ccf5f..94b9bdd2 100644 --- a/crates/zu/tests/refusal_shape.rs +++ b/crates/zu/tests/refusal_shape.rs @@ -70,7 +70,7 @@ const REFUSALS: &[(&str, &str)] = &[ ), ( "a type this file cannot write", - "CREATE GRAPH TYPE t6 { (:Probe {total :: DECIMAL(12,2)}) }", + "CREATE GRAPH TYPE t6 { (:Probe {total :: DECIMAL(38,2)}) }", ), ( "one property declared twice", diff --git a/crates/zu/tests/refusals.rs b/crates/zu/tests/refusals.rs index 3caca02a..2b4f09d4 100644 --- a/crates/zu/tests/refusals.rs +++ b/crates/zu/tests/refusals.rs @@ -70,7 +70,7 @@ fn refusals() -> Vec { Refusal { asks: "a type no column holds", setup: &[], - source: "CREATE GRAPH TYPE v1 { (:P {v :: DECIMAL(12,2)}) }", + source: "CREATE GRAPH TYPE v1 { (:P {v :: DECIMAL(38,2)}) }", says_corrupt: false, status: Some("42000"), }, diff --git a/crates/zu/tests/snapshots/declaration-refusals.txt b/crates/zu/tests/snapshots/declaration-refusals.txt index 02588d99..744f448e 100644 --- a/crates/zu/tests/snapshots/declaration-refusals.txt +++ b/crates/zu/tests/snapshots/declaration-refusals.txt @@ -49,7 +49,7 @@ ^ # a type this file cannot write - statement CREATE GRAPH TYPE t6 { (:Probe {total :: DECIMAL(12,2)}) } + statement CREATE GRAPH TYPE t6 { (:Probe {total :: DECIMAL(38,2)}) } gqlstatus 42000 condition syntax error or access rule violation severity X