Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions conformance/cases/error.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,23 @@ cases:
- type: INT64
value: "18"

- name: an-int128-column-is-a-declaration-a-graph-type-takes
doc: A signed hundred and twenty eight bit integer is a column, stored as sixteen bytes a row at a fixed stride rather than in the sixty four bit lane every other number rides. It reads back as an exact numeric of scale nought, which is a carrier the engine has for the whole of that range. 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 wide { (:Ledger {n :: INT128}) }"
query: RETURN 128 AS bits
columns:
- bits
rows:
- values:
- type: INT64
value: "128"

- name: an-unsigned-wide-integer-no-value-can-name
doc: The unsigned hundred and twenty eight bit integer is refused where the signed one is taken, and the reason is the value side rather than the storage side. Sixteen bytes would hold one; the engine's exact numeric carries a signed hundred and twenty eight bit number, so the top half of the unsigned range is a range no value could name. A column that took a row in and could not give it back would be worse than a refusal at the declaration, which is where the user wrote it.
query: "CREATE GRAPH TYPE t { (:P {v :: UINT128}) }"
raises: "42000"

- name: an-embedding-column-is-a-declaration-a-graph-type-takes
doc: A property declared LIST<FLOAT32 NOT NULL>[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:
Expand Down
115 changes: 108 additions & 7 deletions crates/zu-zu1/src/props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,11 @@ use crate::txn::Cell;
/// lives in the type. Version 12 lets a list column's rows say their
/// count in fewer than four bytes, and writes the width they said it in
/// into the column entry, which is the second field there that is about
/// the encoding rather than about the declaration.
const PROPS_VERSION: u16 = 12;
/// the encoding rather than about the declaration. Version 13 adds the
/// hundred and twenty eight bit integer, which is the first column type
/// that is a number and does not ride the lane: sixteen bytes at a fixed
/// stride, on the side of the store a `BINARY(16)` already uses.
const PROPS_VERSION: u16 = 13;
const MAX_NAME_LEN: usize = 256;

/// The code a list column is written under, followed on disk by the
Expand Down Expand Up @@ -234,7 +237,10 @@ fn type_bytes(ty: &LogicalType) -> Option<Vec<u8>> {
/// 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.
/// not, because that answer has to be the same for the catalog. An
/// integer wider than the lane takes the same road for the same reason:
/// `INT128` has no code, and which of the wide integers has an encoding
/// at all is a question `extended_bytes` answers once for both callers.
///
/// 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
Expand All @@ -248,7 +254,8 @@ fn column_type_bytes(ty: &LogicalType) -> Option<Vec<u8>> {
| LogicalType::Bytes { .. }
| LogicalType::ZonedTime
| LogicalType::ZonedDatetime
| LogicalType::Decimal { .. } => extended_bytes(ty),
| LogicalType::Decimal { .. }
| LogicalType::Int { .. } => extended_bytes(ty),
_ => None,
}
}
Expand Down Expand Up @@ -423,6 +430,12 @@ const EXT_LIST: u8 = 4;
/// read a row: the lane holds unscaled units and the scale is what says
/// a unit is a hundredth.
const EXT_DECIMAL: u8 = 5;
/// A signed hundred and twenty eight bit integer. It takes nothing
/// beside the code, because the type is the whole of what it says, and
/// it is in the extended form rather than in [`TYPE_CODES`] because that
/// table doubles as the list element table and no list element is
/// sixteen bytes wide.
const EXT_INT128: u8 = 6;

/// 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.
Expand Down Expand Up @@ -458,6 +471,19 @@ fn extended_bytes(ty: &LogicalType) -> Option<Vec<u8>> {
Some(match ty {
LogicalType::ZonedTime => vec![EXTENDED_CODE, EXT_ZONED_TIME],
LogicalType::ZonedDatetime => vec![EXTENDED_CODE, EXT_ZONED_DATETIME],
// The signed hundred and twenty eight bit integer, and that one
// alone of the wide integers. `UINT128` is not here because the
// engine's exact numeric carries an `i128`, so the top half of a
// `u128` is a range no value could name, and `INT256` is not
// here because the engine has no carrier for one at all. A
// column whose declared range no value can reach would take a
// row in and refuse to give it back, so the refusal lands at the
// declaration instead, the way the wide decimal's does.
LogicalType::Int {
signed: true,
bits: IntBits::B128,
precision: None,
} => vec![EXTENDED_CODE, EXT_INT128],
// 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
Expand Down Expand Up @@ -530,6 +556,13 @@ fn decode_extended_type(bytes: &[u8], pos: &mut usize) -> Result<LogicalType> {
if kind == EXT_ZONED_DATETIME {
return Ok(LogicalType::ZonedDatetime);
}
if kind == EXT_INT128 {
return Ok(LogicalType::Int {
signed: true,
bits: IntBits::B128,
precision: None,
});
}
if kind == EXT_DECIMAL {
let mut digits = || -> Result<u16> {
let end = *pos + 2;
Expand Down Expand Up @@ -1117,6 +1150,15 @@ impl PropValues<'_> {
// 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.
// And a hundred and twenty eight bit integer holds none of
// sixteen byte runs, because it is on the blob side. The two
// integer arms are two arms for the same reason a `BINARY`
// column and an `INT64` one are: what an empty column holds
// none of is whatever its rows would have been.
LogicalType::Int {
bits: IntBits::B128,
..
} => PropValues::Bytes(&[]),
LogicalType::Int { .. } | LogicalType::Decimal { .. } => PropValues::Int(&[]),
LogicalType::Float { .. } => PropValues::Float(&[]),
LogicalType::Str { .. } => PropValues::Str(&[]),
Expand Down Expand Up @@ -1919,13 +1961,25 @@ fn write_props(
/// characters and a character is one to four octets, so the width it
/// fixes is a width in a unit the storage does not count in. A bound on
/// characters is a check; a bound on octets is a layout.
///
/// A hundred and twenty eight bit integer is one of these, and it is the
/// only one that is not a byte string. The lane is sixty four bits and
/// this is twice that, so the number goes on the blob side of the store
/// as sixteen little endian bytes, which is where a `BINARY(16)` already
/// goes and by the same layout: one width, no offsets. What tells the
/// two apart afterwards is the column's own type, the way it tells a
/// byte string from a character string.
pub(crate) fn fixed_octets(ty: &LogicalType) -> Option<u32> {
match ty {
LogicalType::Bytes {
min: Some(min),
max: Some(max),
..
} if min == max && *min > 0 => Some(*min),
LogicalType::Int {
bits: IntBits::B128,
..
} => Some(16),
_ => None,
}
}
Expand Down Expand Up @@ -1986,7 +2040,33 @@ fn check_declared(
// than a different encoding. What is left to check is the
// promise: a word outside the declared range is a row the
// column's own type says is not there.
(LogicalType::Int { signed, bits, .. }, PropValues::Int(words)) => {
// A hundred and twenty eight bit integer is the exception, and
// it is one because it is not on the lane: sixteen bytes a row
// at a fixed stride, so what is checked is the width and not a
// range. The type is the whole of what a value of it may be, and
// a row of another length is one the reader would walk into the
// next row for.
(
LogicalType::Int {
bits: IntBits::B128,
..
},
PropValues::Bytes(rows),
) => {
for (row, bytes) in rows.iter().enumerate() {
if !column.holds(row) || bytes.len() == 16 {
continue;
}
return Err(ZuError::InvalidArgument(format!(
"column '{name}' is declared {ty} and row {row} holds {} octets",
bytes.len()
)));
}
return Ok(());
}
(LogicalType::Int { signed, bits, .. }, PropValues::Int(words))
if *bits != IntBits::B128 =>
{
for (row, &word) in words.iter().enumerate() {
if !column.holds(row) || bits.holds(word, *signed) {
continue;
Expand Down Expand Up @@ -5947,6 +6027,27 @@ mod tests {
assert!(column_type_bytes(&ty).is_none(), "{ty}");
assert!(!storable(&ty), "{ty}");
}
// The wide integers split the same way and for the same reason.
// A signed hundred and twenty eight bit column is storable,
// because the engine's exact numeric carries an `i128` and so
// every value the declaration admits is a value that can be
// handed back. The unsigned one and the two hundred and fifty
// six bit one are not, because half of one range and all of the
// other have no carrier, and a column that took a row in and
// could not give it back would be worse than a refusal at the
// declaration.
let wide = LogicalType::int(IntBits::B128);
assert_eq!(column_type_bytes(&wide), declared_type_bytes(&wide));
assert!(storable(&wide), "{wide}");
for ty in [
LogicalType::uint(IntBits::B128),
LogicalType::int(IntBits::B256),
LogicalType::uint(IntBits::B256),
] {
assert!(declared_type_bytes(&ty).is_none(), "{ty}");
assert!(column_type_bytes(&ty).is_none(), "{ty}");
assert!(!storable(&ty), "{ty}");
}
}

#[test]
Expand Down Expand Up @@ -6139,6 +6240,7 @@ mod tests {
},
true,
),
(LogicalType::int(IntBits::B128), 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.
(
Expand All @@ -6148,7 +6250,6 @@ mod tests {
},
false,
),
(LogicalType::int(IntBits::B128), false),
(LogicalType::int(IntBits::B256), false),
(LogicalType::uint(IntBits::B128), false),
(LogicalType::float(FloatBits::B16), false),
Expand Down Expand Up @@ -6212,7 +6313,7 @@ mod tests {
// encoding S2 owes, and taking one off is a deliberate diff.
assert_eq!(
unstorable.len(),
19,
18,
"the unstorable set changed: {unstorable:?}"
);
}
Expand Down
13 changes: 13 additions & 0 deletions crates/zu/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ fn sqlite_type(ty: &LogicalType, name: &str) -> Result<ColumnType> {
LogicalType::Bytes { .. } => ColumnType::Blob,
LogicalType::Float { .. } => ColumnType::Real,
LogicalType::Bool => ColumnType::Boolean,
// sqlite's INTEGER is sixty four bits wide, so the hundred and
// twenty eight bit integer has no storage class here either. It
// is refused rather than narrowed, because a staging form that
// silently drops the top half of a value is worse than one that
// says it cannot carry it.
LogicalType::Int {
bits: IntBits::B128,
..
} => {
return Err(ZuError::InvalidArgument(format!(
"column '{name}' holds {ty}, which has no sqlite storage class"
)));
}
LogicalType::Int { .. } => ColumnType::Integer,
// The temporal columns keep their declaration on the way out,
// so a file that round trips through sqlite comes back holding
Expand Down
60 changes: 60 additions & 0 deletions crates/zu/src/declare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,66 @@ mod tests {
assert_eq!(err.gqlstatus().map(|s| s.code()), Some("22003"));
}

/// An `INT128` column holds numbers no lane word could, and hands
/// them back whole.
///
/// This is the first column that is a number and is not stored in
/// the scalar lane. Sixteen little endian bytes a row at a fixed
/// stride is the layout `BINARY(16)` already had, so the storage
/// side asked for nothing new; what is new is that the bytes are
/// read as a number rather than as a run of octets.
///
/// A value comes back as an exact numeric of scale nought, which is
/// what a whole number is and what the engine has a carrier for. It
/// prints without a point and compares equal to the integer of the
/// same size, so a caller who wrote 7 reads 7.
#[test]
fn a_declared_int128_column_holds_a_number_wider_than_a_lane() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("wide.zu1");
seeded(&path);
let mut session = Session::open(&path).expect("open");
// The largest and smallest values thirty eight digits can spell,
// which is the widest a declaration may ask a decimal for, and a
// small one to show the ordinary case still reads plainly.
let big = "99999999999999999999999999999999999999";
for stmt in [
"CREATE PROPERTY GRAPH TYPE t { (:ledger {n :: INT128}) }".to_string(),
"CREATE GRAPH g TYPED t".to_string(),
"USE g INSERT (l:ledger {n: 7})".to_string(),
format!("USE g INSERT (l:ledger {{n: CAST('{big}' AS DECIMAL(38,0))}})"),
format!("USE g INSERT (l:ledger {{n: CAST('-{big}' AS DECIMAL(38,0))}})"),
] {
session
.run(&stmt, &[])
.expect("the graph, its type, its rows");
}

let rows = session
.run("USE g MATCH (l:ledger) RETURN l.n AS n ORDER BY n", &[])
.expect("the rows read back");
let read: Vec<String> = rows
.rows
.iter()
.map(|row| match &row[0] {
Value::Decimal(d) => d.to_string(),
other => panic!("expected an exact numeric, got {other:?}"),
})
.collect();
assert_eq!(read, [format!("-{big}"), "7".to_string(), big.to_string()]);

// A number with a fraction is not a value of an integer column,
// and it is refused rather than rounded, which is the rule the
// decimal column above set.
let err = session
.run(
"USE g INSERT (l:ledger {n: CAST('1.5' AS DECIMAL(2,1))})",
&[],
)
.expect_err("an integer column holds no halves");
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
Expand Down
36 changes: 35 additions & 1 deletion crates/zu/src/insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;

use zu_common::gqlstatus::{Subject, codes};
use zu_common::{Decimal, FloatBits, GqlStatus, LogicalType, Result, Temporal, ZuError};
use zu_common::{Decimal, FloatBits, GqlStatus, IntBits, LogicalType, Result, Temporal, ZuError};
use zu_query::binder::{BoundExpr, BoundInsertNode, BoundInsertRel};

use crate::deleted::Deleted;
Expand Down Expand Up @@ -684,6 +684,40 @@ pub(crate) fn cell(ty: &LogicalType, value: &Value, key: &str) -> Result<Cell> {
};
Ok(match (ty, value) {
(LogicalType::Bool, Value::Bool(b)) => Cell::Int(u64::from(*b)),
// A hundred and twenty eight bit integer goes down the blob side
// of the store, as sixteen little endian bytes, because a cell
// is one word and this is two. It has to come before the integer
// arms below or the word would go in the lane and the top half
// of the number would be gone.
//
// What it takes is an exact numeric of scale nought, which is
// what a whole number is, and an `Int`, which is one already. A
// value with a fraction is refused rather than rounded, for the
// reason a decimal column refuses one: the caller who meant to
// drop it says so with a cast.
(
LogicalType::Int {
bits: IntBits::B128,
..
},
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"),
};
match given.rescale(0) {
Some(whole) => Cell::Str(whole.unscaled().to_le_bytes().to_vec()),
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 column declared narrower than the lane is a promise about
// the values, and this is where the promise is kept: the word
// that goes in is the same word either way, so nothing later
Expand Down
Loading
Loading