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
9 changes: 9 additions & 0 deletions changelog.d/range-bound-parsing.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- New schema declarations interpret `@range(p, ..max)` as an inclusive
maximum and reject integer bounds outside `I64` instead of rounding them
to `F64`. Existing accepted graph schemas retain their stored bounds;
before reapplying or importing their source, write an old upper-only bound
as `min..` and an oversized integer bound as a decimal with its recorded,
possibly rounded value. Complete outstanding deployments containing
affected source before upgrading the binary. See [range constraints][range-bound-parsing-guide].

[range-bound-parsing-guide]: ../docs/user/schema/index.md#constraints
6 changes: 6 additions & 0 deletions changelog.d/range-constraint-exact-comparison.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- A graph whose schema declares `@range` on a list property now refuses a
load, mutation or merge that writes a list element outside the range,
with `@range violation`; the declaration was not enforced before. Rows
already stored are left as they are. Writers that relied on the unchecked
declaration must keep elements inside the range: schema migration refuses
removing a constraint from a property that stays.
9 changes: 9 additions & 0 deletions changelog.d/range-constraint-exact-comparison.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- `@range` compares values with their bounds exactly. Both sides used to be
rounded to a double, so an `I64` value above 2^53 could pass a maximum it
exceeds by one, and a `U64` value above the largest `I64` could pass a
maximum at that value. A decimal bound on an integer property, and an
integer bound on a float property, are compared without rounding as well.
`@range` on a list property now bounds each element; such a declaration
used to check nothing. See [constraints][range-constraint-exact-guide].

[range-constraint-exact-guide]: ../docs/user/schema/index.md#constraints
23 changes: 23 additions & 0 deletions crates/omnigraph-compiler/src/catalog/schema_shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ pub struct SchemaShape {
}

impl SchemaShape {
/// Reconstruct the historical parser's interpretation of upper-only
/// ranges. This is only a candidate for matching an already accepted IR;
/// it must never be used to admit or rewrite a schema.
#[doc(hidden)]
pub fn with_historical_range_endpoints(&self) -> Option<Self> {
let mut shape = self.clone();
let mut changed = false;
// Range constraints are node-only, including persisted admission.
for node in &mut shape.nodes {
for constraint in &mut node.constraints {
if let Constraint::Range { min, max, .. } = constraint
&& min.is_none()
&& max.is_some()
{
*min = max.take();
changed = true;
}
}
node.constraints = canonical_constraints(&node.constraints);
}
changed.then_some(shape)
}

/// Canonicalize role spellings using the accepted graph's source dialect.
pub fn canonicalized_for_system_columns(&self, system_columns: SystemColumns) -> Cow<'_, Self> {
if system_columns != SYSTEM_COLUMNS_LEGACY {
Expand Down
119 changes: 63 additions & 56 deletions crates/omnigraph-compiler/src/schema/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ pub fn parse_schema_for_vintage(

/// Parse source that is already bound to a persisted accepted schema contract.
///
/// This compatibility seam permits exactly one historical admission bug:
/// body-level `@unique(...)` could contain a Blob property before v0.10. New
/// schema admission must always use [`parse_schema`], which rejects that shape.
/// Property-level Blob annotations remain rejected in both modes.
/// This compatibility seam preserves historical body-level `@unique(Blob)`
/// and integer range bounds that overflowed I64 and were stored as Float. New
/// schema admission must always use [`parse_schema`], which rejects those shapes.
/// Property-level Blob annotations remain rejected in both modes. Historical
/// upper-only range endpoint interpretation is accepted only by the caller's
/// complete source-shape comparison with the already accepted schema IR.
#[doc(hidden)]
pub fn parse_persisted_schema_contract(input: &str) -> Result<SchemaFile> {
parse_schema_diagnostic_with_mode(input, ConstraintValidationMode::PersistedContract)
Expand Down Expand Up @@ -97,8 +99,10 @@ fn parse_schema_diagnostic_with_modes(
if pair.as_rule() == Rule::schema_file {
for inner in pair.into_inner() {
if let Rule::schema_decl = inner.as_rule() {
declarations
.push(parse_schema_decl(inner).map_err(compiler_error_to_diagnostic)?);
declarations.push(
parse_schema_decl(inner, constraint_mode)
.map_err(compiler_error_to_diagnostic)?,
);
}
}
}
Expand Down Expand Up @@ -226,12 +230,15 @@ fn compiler_error_to_diagnostic(err: CompilerError) -> ParseDiagnostic {
ParseDiagnostic::new(err.to_string(), None)
}

fn parse_schema_decl(pair: pest::iterators::Pair<Rule>) -> Result<SchemaDecl> {
fn parse_schema_decl(
pair: pest::iterators::Pair<Rule>,
mode: ConstraintValidationMode,
) -> Result<SchemaDecl> {
let inner = pair.into_inner().next().unwrap();
match inner.as_rule() {
Rule::interface_decl => Ok(SchemaDecl::Interface(parse_interface_decl(inner)?)),
Rule::node_decl => Ok(SchemaDecl::Node(parse_node_decl(inner)?)),
Rule::edge_decl => Ok(SchemaDecl::Edge(parse_edge_decl(inner)?)),
Rule::node_decl => Ok(SchemaDecl::Node(parse_node_decl(inner, mode)?)),
Rule::edge_decl => Ok(SchemaDecl::Edge(parse_edge_decl(inner, mode)?)),
_ => Err(CompilerError::Parse(format!(
"unexpected rule: {:?}",
inner.as_rule()
Expand All @@ -253,7 +260,10 @@ fn parse_interface_decl(pair: pest::iterators::Pair<Rule>) -> Result<InterfaceDe
Ok(InterfaceDecl { name, properties })
}

fn parse_node_decl(pair: pest::iterators::Pair<Rule>) -> Result<NodeDecl> {
fn parse_node_decl(
pair: pest::iterators::Pair<Rule>,
mode: ConstraintValidationMode,
) -> Result<NodeDecl> {
let mut inner = pair.into_inner();
let name = inner.next().unwrap().as_str().to_string();

Expand All @@ -278,7 +288,7 @@ fn parse_node_decl(pair: pest::iterators::Pair<Rule>) -> Result<NodeDecl> {
properties.push(parse_prop_decl(item)?);
}
Rule::body_constraint => {
constraints.push(parse_body_constraint(item)?);
constraints.push(parse_body_constraint(item, mode)?);
}
_ => {}
}
Expand Down Expand Up @@ -312,7 +322,10 @@ fn parse_node_decl(pair: pest::iterators::Pair<Rule>) -> Result<NodeDecl> {
})
}

fn parse_edge_decl(pair: pest::iterators::Pair<Rule>) -> Result<EdgeDecl> {
fn parse_edge_decl(
pair: pest::iterators::Pair<Rule>,
mode: ConstraintValidationMode,
) -> Result<EdgeDecl> {
let mut inner = pair.into_inner();
let name = inner.next().unwrap().as_str().to_string();
let from_type = inner.next().unwrap().as_str().to_string();
Expand All @@ -330,7 +343,7 @@ fn parse_edge_decl(pair: pest::iterators::Pair<Rule>) -> Result<EdgeDecl> {
}
Rule::annotation => annotations.push(parse_annotation(item)?),
Rule::prop_decl => properties.push(parse_prop_decl(item)?),
Rule::body_constraint => constraints.push(parse_body_constraint(item)?),
Rule::body_constraint => constraints.push(parse_body_constraint(item, mode)?),
_ => {}
}
}
Expand Down Expand Up @@ -377,7 +390,10 @@ fn parse_cardinality(pair: pest::iterators::Pair<Rule>) -> Result<Cardinality> {
Ok(Cardinality { min, max })
}

fn parse_body_constraint(pair: pest::iterators::Pair<Rule>) -> Result<Constraint> {
fn parse_body_constraint(
pair: pest::iterators::Pair<Rule>,
mode: ConstraintValidationMode,
) -> Result<Constraint> {
let mut inner = pair.into_inner();
let name_pair = inner.next().unwrap();
let constraint_name = name_pair.as_str();
Expand Down Expand Up @@ -425,7 +441,7 @@ fn parse_body_constraint(pair: pest::iterators::Pair<Rule>) -> Result<Constraint
}
let property = extract_ident_from_constraint_arg(args[0].clone())?;
// The second arg should be a range_bound
let (min, max) = extract_range_bounds(&args[1])?;
let (min, max) = extract_range_bounds(&args[1], mode)?;
Ok(Constraint::Range { property, min, max })
}
"check" => {
Expand Down Expand Up @@ -491,6 +507,7 @@ fn extract_string_from_constraint_arg(pair: &pest::iterators::Pair<Rule>) -> Res

fn extract_range_bounds(
pair: &pest::iterators::Pair<Rule>,
mode: ConstraintValidationMode,
) -> Result<(Option<ConstraintBound>, Option<ConstraintBound>)> {
// Find the range_bound node inside the constraint_arg
let range_pair = if pair.as_rule() == Rule::range_bound {
Expand All @@ -512,55 +529,45 @@ fn extract_range_bounds(

let mut min = None;
let mut max = None;
let mut seen_bound = false;

for child in range_pair.into_inner() {
if child.as_rule() == Rule::literal
|| child.as_rule() == Rule::integer
|| child.as_rule() == Rule::float_lit
|| child.as_rule() == Rule::signed_integer
|| child.as_rule() == Rule::signed_float
{
let bound = parse_constraint_bound(&child)?;
if !seen_bound {
min = Some(bound);
seen_bound = true;
} else {
max = Some(bound);
}
for endpoint in range_pair.into_inner() {
let role = endpoint.as_rule();
let value = endpoint
.into_inner()
.next()
.ok_or_else(|| CompilerError::Parse("missing value in range bound".to_string()))?;
let bound = parse_constraint_bound(&value, mode)?;
match role {
Rule::range_min => min = Some(bound),
Rule::range_max => max = Some(bound),
_ => unreachable!("range endpoints are named by the grammar"),
}
}

Ok((min, max))
}

fn parse_constraint_bound(pair: &pest::iterators::Pair<Rule>) -> Result<ConstraintBound> {
fn parse_constraint_bound(
pair: &pest::iterators::Pair<Rule>,
mode: ConstraintValidationMode,
) -> Result<ConstraintBound> {
let text = pair.as_str();

// Try as integer first
if let Ok(n) = text.parse::<i64>() {
return Ok(ConstraintBound::Integer(n));
}
// Try as float
if let Ok(f) = text.parse::<f64>() {
return Ok(ConstraintBound::Float(f));
}

// Navigate into literal -> integer/float_lit
for inner in pair.clone().into_inner() {
let s = inner.as_str();
if let Ok(n) = s.parse::<i64>() {
return Ok(ConstraintBound::Integer(n));
}
if let Ok(f) = s.parse::<f64>() {
return Ok(ConstraintBound::Float(f));
if pair.as_rule() == Rule::signed_integer {
match text.parse::<i64>() {
Ok(value) => return Ok(ConstraintBound::Integer(value)),
Err(_) if mode == ConstraintValidationMode::Admission => {
return Err(CompilerError::Parse(format!(
"integer range bound {text} is outside I64 ({}..{})",
i64::MIN,
i64::MAX,
)));
}
// Accepted contracts may already contain an overflowed integer as
// a Float. Keep that value when checking their persisted source.
Err(_) => {}
}
}

Err(CompilerError::Parse(format!(
"invalid constraint bound: {}",
text
)))
text.parse::<f64>()
.map(ConstraintBound::Float)
.map_err(|_| CompilerError::Parse(format!("invalid constraint bound: {text}")))
}

/// Desugar property-level @key/@unique/@index annotations into body-level constraints.
Expand Down
Loading
Loading