Skip to content
Open
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
2 changes: 1 addition & 1 deletion .gts-spec-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.12.2
v0.13.0
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ chrono = "0.4"

# JSON Schema validation
jsonschema = { version = "0.40", default-features = false }
# Exact comparison between differently typed numbers, as used by `jsonschema`.
num-cmp = "0.1"

# JSON Schema generation
schemars = { version = "1.2", features = ["uuid1"] }
Expand Down
50 changes: 23 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ gts --path ../gts-spec/examples resolve-relationships --gts-id "gts.x.core.event

#### OP#8 - Compatibility Checking

Verify that schemas with different MINOR versions are compatible.
Verify schema evolution using GTS 0.13 accepted-instance set inclusion.

```bash
# Check compatibility between schema versions
Expand All @@ -325,12 +325,14 @@ gts --path ../gts-spec/examples compatibility \
"added_properties": [],
"removed_properties": [],
"changed_properties": [],
"is_fully_compatible": true,
"is_backward_compatible": true,
"is_forward_compatible": true,
"full_compatibility": "compatible",
"backward_compatibility": "compatible",
"forward_compatibility": "compatible",
"incompatibility_reasons": [],
"backward_errors": [],
"forward_errors": []
"forward_errors": [],
"specification_version": "0.13",
"implementation_version": "0.11.0"
}
```

Expand Down Expand Up @@ -359,9 +361,9 @@ gts --path ../gts-spec/examples cast \
"direction": "unknown",
"added_properties": ["payload.new_field_in_v1_1"],
"removed_properties": [],
"is_fully_compatible": true,
"is_backward_compatible": true,
"is_forward_compatible": true,
"full_compatibility": "compatible",
"backward_compatibility": "compatible",
"forward_compatibility": "compatible",
"casted_entity": {
"id": "7a1d2f34-5678-49ab-9012-abcdef123456",
"type": "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~",
Expand Down Expand Up @@ -505,7 +507,7 @@ All operations are available through the `GtsOps` API.
#### Setup

```rust
use gts::{GtsId, GtsOps, GtsConfig, GtsIdPattern};
use gts::{CompatibilityVerdict, GtsConfig, GtsId, GtsIdPattern, GtsOps};
use serde_json::json;

// Initialize GTS operations with data paths
Expand Down Expand Up @@ -677,27 +679,21 @@ let result = ops.compatibility(
);

// OP#8.1 - Backward compatibility
if result.is_backward_compatible {
println!("Old instances work with new schema");
} else {
println!("Backward incompatible:");
for error in result.backward_errors {
println!(" - {}", error);
}
match result.backward_compatibility {
CompatibilityVerdict::Compatible => println!("Old instances work with new schema"),
CompatibilityVerdict::Incompatible => println!("Known backward-incompatible"),
CompatibilityVerdict::Unknown => println!("Backward compatibility could not be determined"),
}

// OP#8.2 - Forward compatibility
if result.is_forward_compatible {
println!("New instances work with old schema");
} else {
println!("Forward incompatible:");
for error in result.forward_errors {
println!(" - {}", error);
}
match result.forward_compatibility {
CompatibilityVerdict::Compatible => println!("New instances work with old schema"),
CompatibilityVerdict::Incompatible => println!("Known forward-incompatible"),
CompatibilityVerdict::Unknown => println!("Forward compatibility could not be determined"),
}

// OP#8.3 - Full compatibility
if result.is_fully_compatible {
if result.full_compatibility.is_compatible() {
println!("Fully compatible in both directions");
}
```
Expand All @@ -722,8 +718,8 @@ if let Some(casted) = result.casted_entity {
}

// Check compatibility
if !result.is_backward_compatible {
println!("Warning: Not backward compatible");
if !result.backward_compatibility.is_compatible() {
println!("Warning: backward compatibility is not established");
for reason in result.incompatibility_reasons {
println!(" - {}", reason);
}
Expand Down Expand Up @@ -834,7 +830,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~",
"gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~"
);
println!("Backward compatible: {}", compat.is_backward_compatible);
println!("Backward compatible: {}", compat.backward_compatibility);

// OP#9: Cast instance (instance identified by UUID)
let cast = ops.cast(
Expand Down
1 change: 1 addition & 0 deletions gts-dylint/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 44 additions & 4 deletions gts-id/src/gts_id_pattern.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,21 @@ impl GtsIdPattern {
/// [`GtsId::matches_pattern`]: crate::GtsId::matches_pattern
pub(crate) fn matches_views<C: SegmentView>(&self, candidate: &[C]) -> bool {
let pattern_segs = &self.segments;
// If pattern is longer than candidate, no match
if pattern_segs.len() > candidate.len() {
// A final bare `~*` may match an empty chain suffix. A wildcard that
// already specifies part of the next segment (for example `~abc.*`)
// still requires that segment to exist.
let matches_empty_suffix = pattern_segs
.last()
.is_some_and(|seg| seg.is_wildcard() && seg.raw() == "*");
let required_candidate_len = pattern_segs.len() - usize::from(matches_empty_suffix);
if required_candidate_len > candidate.len() {
return false;
}

for (i, p_seg) in pattern_segs.iter().enumerate() {
if i == candidate.len() {
return matches_empty_suffix && i == pattern_segs.len() - 1;
}
let c_seg = &candidate[i];

// If pattern segment is a wildcard, only its specified (non-empty)
Expand All @@ -110,7 +119,9 @@ impl GtsIdPattern {
if !p_seg.type_name().is_empty() && p_seg.type_name() != c_seg.type_name() {
return false;
}
if p_seg.ver_major() != 0 && p_seg.ver_major() != c_seg.ver_major() {
if let Some(p_major) = p_seg.ver_major_opt()
&& Some(p_major) != c_seg.ver_major_opt()
{
return false;
}
if let Some(p_minor) = p_seg.ver_minor()
Expand Down Expand Up @@ -147,7 +158,7 @@ impl GtsIdPattern {
}

// Check version matching
if p_seg.ver_major() != c_seg.ver_major() {
if p_seg.ver_major_opt() != c_seg.ver_major_opt() {
return false;
}

Expand Down Expand Up @@ -309,6 +320,35 @@ mod tests {
assert!(instance_candidate.matches_pattern(&pattern));
}

#[test]
fn test_trailing_chain_wildcard_matches_empty_suffix() {
let pattern = GtsIdPattern::try_new(&gts_id("x.core.events.topic.v1~*")).expect("test");
let exact = GtsId::try_new(&gts_id("x.core.events.topic.v1~")).expect("test");
let specific_minor = GtsId::try_new(&gts_id("x.core.events.topic.v1.1~")).expect("test");

assert!(exact.matches_pattern(&pattern));
assert!(specific_minor.matches_pattern(&pattern));
}

#[test]
fn test_prefixed_chain_wildcard_requires_a_suffix() {
let pattern = GtsIdPattern::try_new(&gts_id("x.core.events.topic.v1~abc.*")).expect("test");
let base = GtsId::try_new(&gts_id("x.core.events.topic.v1~")).expect("test");

assert!(!base.matches_pattern(&pattern));
}

#[test]
fn test_zero_major_minor_wildcard_is_scoped_to_v0() {
let pattern = GtsIdPattern::try_new(&gts_id("x.core.events.topic.v0.*")).expect("test");
let v0 = GtsId::try_new(&gts_id("x.core.events.topic.v0.2~")).expect("test");
let v1 = GtsId::try_new(&gts_id("x.core.events.topic.v1.2~")).expect("test");

assert_eq!(pattern.segments()[0].ver_major_opt(), Some(0));
assert!(v0.matches_pattern(&pattern));
assert!(!v1.matches_pattern(&pattern));
}

#[test]
fn test_gts_wildcard_type_suffix() {
// Wildcard after ~ should match type IDs
Expand Down
59 changes: 46 additions & 13 deletions gts-id/src/gts_id_segment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ use crate::parse::{expected_format, is_valid_segment_token, parse_u32_exact};
///
/// For a wildcard segment these fields hold the (possibly partial) prefix that
/// precedes the `*` token — e.g. `x.core.*` fills `vendor` and `package` and
/// leaves the rest empty. Empty strings, a zero `ver_major`, and a `None`
/// `ver_minor` therefore mean "unspecified" in the wildcard case.
/// leaves the rest empty. Empty strings and `None` version components therefore
/// mean "unspecified" in the wildcard case. A present major version may
/// legitimately be zero.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GtsIdSegmentParts {
/// The raw segment string as it appeared in the source (including any
Expand All @@ -34,7 +35,7 @@ pub struct GtsIdSegmentParts {
package: String,
namespace: String,
type_name: String,
ver_major: u32,
ver_major: Option<u32>,
ver_minor: Option<u32>,
}

Expand Down Expand Up @@ -72,8 +73,17 @@ impl GtsIdSegmentParts {
}

/// The major version, or `0` when unspecified in a wildcard segment.
///
/// Use [`Self::ver_major_opt`] when the distinction between an unspecified
/// version and a real `v0` matters.
#[must_use]
pub fn ver_major(&self) -> u32 {
self.ver_major.unwrap_or(0)
}

/// The major version when one was specified.
#[must_use]
pub fn ver_major_opt(&self) -> Option<u32> {
self.ver_major
}

Expand All @@ -100,7 +110,7 @@ pub trait SegmentView {
fn package(&self) -> &str;
fn namespace(&self) -> &str;
fn type_name(&self) -> &str;
fn ver_major(&self) -> u32;
fn ver_major_opt(&self) -> Option<u32>;
fn ver_minor(&self) -> Option<u32>;
fn is_type(&self) -> bool;
fn uuid_tail(&self) -> Option<&str>;
Expand Down Expand Up @@ -198,9 +208,18 @@ impl GtsIdSegment {
}

/// The major version, or `0` for a UUID tail.
///
/// Use [`Self::ver_major_opt`] when the distinction between an absent
/// version and a real `v0` matters.
#[must_use]
pub fn ver_major(&self) -> u32 {
self.parts().map_or(0, |p| p.ver_major)
self.parts().map_or(0, GtsIdSegmentParts::ver_major)
}

/// The major version when this is a named GTS segment.
#[must_use]
pub fn ver_major_opt(&self) -> Option<u32> {
self.parts().and_then(GtsIdSegmentParts::ver_major_opt)
}

/// The minor version, when present.
Expand Down Expand Up @@ -329,11 +348,23 @@ impl GtsIdPatternSegment {
}

/// The major version, or `0` when unspecified.
///
/// Use [`Self::ver_major_opt`] when the distinction between an unspecified
/// version wildcard and a real `v0` matters.
#[must_use]
pub fn ver_major(&self) -> u32 {
match self {
GtsIdPatternSegment::Segment(s) => s.ver_major(),
GtsIdPatternSegment::Wildcard(p) => p.ver_major,
GtsIdPatternSegment::Wildcard(p) => p.ver_major(),
}
}

/// The major version when one was specified in this pattern segment.
#[must_use]
pub fn ver_major_opt(&self) -> Option<u32> {
match self {
GtsIdPatternSegment::Segment(s) => s.ver_major_opt(),
GtsIdPatternSegment::Wildcard(p) => p.ver_major_opt(),
}
}

Expand Down Expand Up @@ -483,7 +514,7 @@ fn parse_segment_parts(
package: String::new(),
namespace: String::new(),
type_name: String::new(),
ver_major: 0,
ver_major: None,
ver_minor: None,
};

Expand Down Expand Up @@ -539,8 +570,10 @@ fn parse_segment_parts(
}

let major_str = &tokens[4][1..];
parts.ver_major = parse_u32_exact(major_str)
.ok_or_else(|| format!("Major version must be an integer, got '{major_str}'"))?;
parts.ver_major = Some(
parse_u32_exact(major_str)
.ok_or_else(|| format!("Major version must be an integer, got '{major_str}'"))?,
);
}

if tokens.len() > 5 {
Expand Down Expand Up @@ -573,8 +606,8 @@ impl SegmentView for GtsIdSegment {
fn type_name(&self) -> &str {
self.type_name()
}
fn ver_major(&self) -> u32 {
self.ver_major()
fn ver_major_opt(&self) -> Option<u32> {
self.ver_major_opt()
}
fn ver_minor(&self) -> Option<u32> {
self.ver_minor()
Expand All @@ -600,8 +633,8 @@ impl SegmentView for GtsIdPatternSegment {
fn type_name(&self) -> &str {
self.type_name()
}
fn ver_major(&self) -> u32 {
self.ver_major()
fn ver_major_opt(&self) -> Option<u32> {
self.ver_major_opt()
}
fn ver_minor(&self) -> Option<u32> {
self.ver_minor()
Expand Down
40 changes: 40 additions & 0 deletions gts-macros/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,46 @@ pub struct MyStructV1 { ... }
pub struct MyStructV1 { ... }
```

### Ordinary Nested Data Structs and Content Models

The macro emits Draft-07 schemas and preserves `definitions` for ordinary nested Rust structs.

Under GTS 0.13, adding an optional field to an **open** object is not backward compatible: the
old schema already accepted arbitrary values under that property name, so declaring it narrows
the set of accepted instances (gts-spec §4.4–§4.5). Schemars leaves a nested struct's object
level open unless it declares `#[serde(deny_unknown_fields)]`, so the macro closes those levels
itself — nested types stay evolvable in place without changing how Serde deserializes them at
runtime.

Levels the macro closes:

- the document root of a base type, and the level carrying a derived type's own properties
(it always did this);
- every nested object level that declares `properties` and states no content model of its own.

Levels the macro deliberately leaves alone:

| Level | Why |
|---|---|
| Generic GTS extension slot | §4.4.1 requires it open so derived types can extend it |
| Map types (`HashMap`, `BTreeMap`) | already partially open via a schema-valued `additionalProperties` |
| A struct that flattens a map | Schemars emits `additionalProperties: true`; closing would be wrong |
| Branches of `allOf`/`anyOf`/`oneOf`/`not`/`if` | `additionalProperties` only sees `properties` from the same schema object, so closing a branch would reject the properties its siblings declare |

To keep a nested level open on purpose — as a designated extension point in the sense of
§4.4.1 — state the content model explicitly and the macro will not touch it:

```rust
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(extend("additionalProperties" = true))]
pub struct ExtensionPoint {
pub label: String,
}
```

`#[serde(deny_unknown_fields)]` also still works and is the right choice when the wire contract
should reject unknown fields at deserialization time as well, not just during schema validation.

### What Gets Validated

| Check | Description |
Expand Down
Loading
Loading