Skip to content

Commit 80c75a3

Browse files
sbernauerclaude
andauthored
feat(versioned): Roundtrip-track changed types (#1296)
* feat(versioned): Add support for from_docs attribute * feat(versioned): Support conversion tracking through enums With experimental_conversion_tracking enabled, versioned enums now implement TrackingFrom instead of From, in line with structs. Variants marked with as an additional path segment (eg. `$.connector.Iceberg.field`). Also fix the generated From impls for variants with multiple unnamed fields, which were missing separating commas. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(versioned): Roundtrip-track changed types * fix(versioned): Track values as JSON Tracked values were stored as serde_yaml::Value, which represents enum variants as YAML tags. After a roundtrip through the JSON status, these could not be deserialized again, which panicked during the upgrade. The status is stored as JSON anyway, so values are now tracked as serde_json::Value. Also fix restoring type changes whose downgraded value is null: it is serialized as `downgradedValue: null` and deserialized as None, so the comparison never matched. A missing downgraded value is now treated as null. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(versioned): Only apply tracked values matching the container Multiple (nested) containers can track values for the same version. Each tracking impl previously removed the whole list of tracked values for that version and panicked on JSON paths it didn't know. This broke as soon as two containers tracked values in the same version, eg. the type change of `gender` and the added `Married::since` in v3 of the person test. Each container now only applies the values matching its JSON paths and keeps all others for the remaining containers. The entry for the version is only removed once all values have been applied. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Remove test * docs: Document edge-case * fmt * cargo update to fix RUSTSEC-2026-0418 --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent a66da25 commit 80c75a3

21 files changed

Lines changed: 1043 additions & 82 deletions

‎Cargo.lock‎

Lines changed: 45 additions & 35 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/stackable-versioned-macros/src/codegen/container/struct/conversion.rs‎

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,11 @@ impl Struct {
176176
// Ideally we would integrate with the serde(rename) functionality to produce these field
177177
// names.
178178
let inserts = self.generate_tracking_inserts(direction, next_version, mod_gen_ctx);
179+
let post_inserts =
180+
self.generate_tracking_post_inserts(direction, next_version, mod_gen_ctx);
179181
let removals = self.generate_tracking_removals(direction, next_version, mod_gen_ctx);
180182
let json_paths = self.generate_json_paths(next_version, mod_gen_ctx);
183+
let tracked_values = self.generate_tracked_values(direction, next_version, mod_gen_ctx);
181184

182185
// TODO (@Techassi): We know the status, so we can hard-code it, but hard to track across structs
183186

@@ -196,10 +199,18 @@ impl Struct {
196199
// the upgrade or downgrade section. Only then we can convert the spec.
197200
#inserts
198201

202+
// Fields which changed their type are consumed by the conversion, so their
203+
// values need to be serialized before converting the spec.
204+
#tracked_values
205+
199206
let mut spec = Self {
200207
#fields
201208
};
202209

210+
// Fields which changed their type can only be inserted into the status after
211+
// the spec is converted, because the downgraded value is tracked as well.
212+
#post_inserts
213+
203214
// After the spec is converted, depending on the direction, we need to apply
204215
// changed values from either the upgrade or downgrade section. Afterwards
205216
// we can return the successfully converted spec and the status contains
@@ -241,6 +252,12 @@ impl Struct {
241252
})
242253
.collect();
243254

255+
// This is the case if only fields which changed their type need to be tracked.
256+
// These are inserted after the conversion, see generate_tracking_post_inserts.
257+
if inserts.is_empty() {
258+
return None;
259+
}
260+
244261
Some(quote! {
245262
let upgrades = status
246263
.changes()
@@ -254,6 +271,42 @@ impl Struct {
254271
}
255272
}
256273

274+
fn generate_tracking_post_inserts(
275+
&self,
276+
direction: Direction,
277+
next_version: &VersionDefinition,
278+
mod_gen_ctx: ModuleGenerationContext<'_>,
279+
) -> Option<TokenStream> {
280+
match direction {
281+
Direction::Upgrade => None,
282+
Direction::Downgrade => {
283+
let next_version_string = next_version.inner.to_string();
284+
285+
let post_inserts: TokenStream = self
286+
.fields
287+
.iter()
288+
.filter_map(|f| {
289+
f.generate_for_status_post_insertion(direction, next_version, mod_gen_ctx)
290+
})
291+
.collect();
292+
293+
if post_inserts.is_empty() {
294+
return None;
295+
}
296+
297+
Some(quote! {
298+
let upgrades = status
299+
.changes()
300+
.upgrades
301+
.entry(#next_version_string.to_owned())
302+
.or_default();
303+
304+
#post_inserts
305+
})
306+
}
307+
}
308+
}
309+
257310
fn generate_tracking_removals(
258311
&self,
259312
direction: Direction,
@@ -274,6 +327,10 @@ impl Struct {
274327
Direction::Upgrade => {
275328
let next_version_string = next_version.inner.to_string();
276329

330+
// Multiple (nested) containers can track values for the same version. As such, only
331+
// the values matching the JSON paths of this container are applied. All other values
332+
// are kept, so that they can be applied by the other containers. The entry for the
333+
// version is only removed once all values have been applied.
277334
Some(quote! {
278335
if let Some(upgrades) = status.changes().upgrades.get_mut(#next_version_string) {
279336
#removals
@@ -300,17 +357,37 @@ impl Struct {
300357
.collect()
301358
}
302359

360+
fn generate_tracked_values(
361+
&self,
362+
direction: Direction,
363+
next_version: &VersionDefinition,
364+
mod_gen_ctx: ModuleGenerationContext<'_>,
365+
) -> TokenStream {
366+
let from_struct_ident = &self.common.idents.parameter;
367+
368+
self.fields
369+
.iter()
370+
.filter_map(|f| {
371+
f.generate_for_tracked_value(
372+
direction,
373+
next_version,
374+
from_struct_ident,
375+
mod_gen_ctx,
376+
)
377+
})
378+
.collect()
379+
}
380+
303381
pub(super) fn needs_tracking(&self, version: &VersionDefinition) -> bool {
304382
self.fields.iter().any(|f| {
305383
f.changes.as_ref().is_some_and(|c| {
306384
c.value_is(&version.inner, |s| {
307-
// For now, only added fields need to be tracked. In the future, removals and
308-
// type changes also need to be tracked
385+
// For now, only added fields and fields which changed their type need to be
386+
// tracked. In the future, removals also need to be tracked.
309387
match s {
310388
ItemStatus::Addition { .. } => true,
311-
// TODO (@Techassi): Support tracking for changed fields
312-
ItemStatus::Change { .. }
313-
| ItemStatus::Deprecation { .. }
389+
ItemStatus::Change { .. } => s.is_type_change(),
390+
ItemStatus::Deprecation { .. }
314391
| ItemStatus::NoChange { .. }
315392
| ItemStatus::NotPresent => false,
316393
}

0 commit comments

Comments
 (0)