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
103 changes: 102 additions & 1 deletion crates/stackable-versioned-macros/src/attrs/item/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub use variant::*;
/// [`Vec`].
/// - An item can only be deprecated once. A field or variant not marked as
/// 'deprecated' will be included up until the latest version.
/// - An item can only be removed once. It is not included in the version it is
/// removed in and all later versions. Removal must be the last action.
#[derive(Debug, FromMeta)]
pub struct CommonItemAttributes {
/// This parses the `added` attribute on items (fields or variants). It can
Expand All @@ -43,6 +45,10 @@ pub struct CommonItemAttributes {
/// This parses the `deprecated` attribute on items (fields or variants). It
/// can only be present at most once.
pub deprecated: Option<DeprecatedAttributes>,

/// This parses the `removed` attribute on items (fields or variants). It
/// can only be present at most once.
pub removed: Option<RemovedAttributes>,
}

// This impl block ONLY contains validation. The main entrypoint is the associated 'validate'
Expand Down Expand Up @@ -90,6 +96,25 @@ impl CommonItemAttributes {
).with_span(&deprecated.version.span()));
}

if let Some(removed) = &self.removed {
if !versions.iter().any(|v| v.inner == *removed.version) {
errors.push(Error::custom(
"the `removed` action uses a version which is not declared via `#[versioned(version)]`",
).with_span(&removed.version.span()));
}

// An item removed in the first version would never exist.
if versions
.first()
.is_some_and(|v| v.inner == *removed.version)
{
errors.push(
Error::custom("cannot be marked as `removed` in the first version")
.with_span(&removed.version.span()),
);
}
}

errors.finish()
}

Expand All @@ -106,7 +131,29 @@ impl CommonItemAttributes {
/// at least one version before being changed.
/// - `changed` and `deprecated` using the same version: Again, the same
/// rules from above apply here as well.
/// - `removed` and any other action using the same version: Again, the
/// same rules from above apply here as well.
fn validate_action_combinations(&self, item_idents: &impl ItemIdents) -> Result<()> {
if let Some(removed) = &self.removed {
let removed_version = *removed.version;

if self
.added
.as_ref()
.is_some_and(|a| *a.version == removed_version)
|| self.changes.iter().any(|c| *c.version == removed_version)
|| self
.deprecated
.as_ref()
.is_some_and(|d| *d.version == removed_version)
{
return Err(Error::custom(
"cannot be marked as `removed` and any other action in the same version",
)
.with_span(item_idents.original()));
}
}

match (&self.added, &self.changes, &self.deprecated) {
(Some(added), _, Some(deprecated)) if *added.version == *deprecated.version => Err(
Error::custom("cannot be marked as `added` and `deprecated` in the same version")
Expand Down Expand Up @@ -142,6 +189,7 @@ impl CommonItemAttributes {
/// version of the added action.
/// - All `changed` actions must use a greater version than `added` but a
/// lesser version than `deprecated`.
/// - `removed` must use a greater version than all other actions.
fn validate_action_order(&self, item_idents: &impl ItemIdents) -> Result<()> {
let added_version = self.added.as_ref().map(|a| *a.version);
let deprecated_version = self.deprecated.as_ref().map(|d| *d.version);
Expand Down Expand Up @@ -169,6 +217,21 @@ impl CommonItemAttributes {
.with_span(item_idents.original()));
}

// Lastly, ensure that the item is removed after all other actions.
if let Some(removed) = &self.removed {
let removed_version = removed.version;

if added_version.is_some_and(|a| a > *removed_version)
|| deprecated_version.is_some_and(|d| d > *removed_version)
|| self.changes.iter().any(|c| *c.version > *removed_version)
{
return Err(Error::custom(
"`removed` must use a version higher than all other actions",
)
.with_span(&removed_version.span()));
}
}

Ok(())
}

Expand Down Expand Up @@ -317,8 +380,32 @@ impl CommonItemAttributes {
.collect()
}

#[expect(clippy::too_many_lines)]
pub fn into_changeset(
mut self,
idents: &impl ItemIdents,
ty: Type,
) -> Option<BTreeMap<Version, ItemStatus>> {
let removed = self.removed.take();
let mut actions = self.into_changeset_without_removal(idents, ty);

// The removal is always the last action (which is ensured during validation). The item uses
// the original ident in the last version it is present in, regardless if it was deprecated
// or not.
if let Some(removed) = removed {
actions.get_or_insert_default().insert(
*removed.version,
ItemStatus::Removal {
default_fn: removed.default_fn.deref().clone(),
ident: idents.original().clone(),
},
);
}

actions
}

#[expect(clippy::too_many_lines)]
fn into_changeset_without_removal(
self,
idents: &impl ItemIdents,
ty: Type,
Expand Down Expand Up @@ -513,3 +600,17 @@ pub struct DeprecatedAttributes {
pub note: Option<SpannedValue<String>>,
pub previous_docs: Option<SpannedValue<String>>,
}

/// For the removed() action
///
/// Example usage:
/// - `removed(in = "...")`
/// - `removed(in = "...", default = "custom_fn")`
#[derive(Clone, Debug, FromMeta)]
pub struct RemovedAttributes {
#[darling(rename = "in")]
pub version: SpannedValue<Version>,

#[darling(rename = "default", default = "default_default_fn")]
pub default_fn: SpannedValue<Path>,
}
9 changes: 9 additions & 0 deletions crates/stackable-versioned-macros/src/attrs/item/variant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ impl VariantAttributes {

errors.handle(self.common.validate(&variant_idents, &self.attrs));

// Removing a variant would require a variant to map to during upgrades, which is currently
// not supported.
if let Some(removed) = &self.common.removed {
errors.push(
Error::custom("the `removed` action is currently not supported on enum variants")
.with_span(&removed.version.span()),
);
}

// Validate names of renames
for change in &self.common.changes {
if let Some(previous_name) = &change.previous_name
Expand Down
20 changes: 18 additions & 2 deletions crates/stackable-versioned-macros/src/codegen/changes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ impl ChangesetExt for BTreeMap<Version, ItemStatus> {
ty: Box::new(ty.clone()),
},
),
// If the removal is the only action, the item is present with its original
// ident and type in all versions before.
ItemStatus::Removal { ident, .. } => self.insert(
version.inner,
ItemStatus::NoChange {
previously_deprecated: false,
ident: ident.clone(),
ty: Box::new(ty.clone()),
},
),
ItemStatus::NoChange {
previously_deprecated,
ident,
Expand Down Expand Up @@ -163,7 +173,11 @@ impl ChangesetExt for BTreeMap<Version, ItemStatus> {
ty,
..
} => (ident, ty, *previously_deprecated),
ItemStatus::NotPresent => unreachable!(),
// The item is not present in any version after it has been removed.
ItemStatus::Removal { .. } | ItemStatus::NotPresent => {
self.insert(version.inner, ItemStatus::NotPresent);
continue;
}
};

self.insert(
Expand All @@ -189,7 +203,9 @@ impl ChangesetExt for BTreeMap<Version, ItemStatus> {
ty,
..
} => (ident, ty, *previously_deprecated),
ItemStatus::NotPresent => {
// NOTE: The removal is always the last action, so it can't have an upper
// neighbor. It is still handled here for completeness.
ItemStatus::Removal { .. } | ItemStatus::NotPresent => {
self.insert(version.inner, ItemStatus::NotPresent);
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ impl Struct {
ItemStatus::Change { .. } => s.is_type_change(),
ItemStatus::Deprecation { .. }
| ItemStatus::NoChange { .. }
| ItemStatus::Removal { .. }
| ItemStatus::NotPresent => false,
}
})
Expand Down
27 changes: 22 additions & 5 deletions crates/stackable-versioned-macros/src/codegen/item/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ impl VersionedField {
pub #field_ident: #field_type,
})
}
ItemStatus::NotPresent => None,
ItemStatus::Removal { .. } | ItemStatus::NotPresent => None,
ItemStatus::NoChange {
previously_deprecated,
ident,
Expand Down Expand Up @@ -225,8 +225,11 @@ impl VersionedField {
match (change, next_change) {
// If both this status and the next one is NotPresent, which means a field was
// introduced after a bunch of versions, we don't need to generate any code for
// the From impl.
(ItemStatus::NotPresent, ItemStatus::NotPresent) => None,
// the From impl. The same applies to all versions after a field was removed.
(
ItemStatus::NotPresent | ItemStatus::Removal { .. },
ItemStatus::NotPresent,
) => None,
(
_,
ItemStatus::Addition {
Expand All @@ -236,6 +239,19 @@ impl VersionedField {
Direction::Upgrade => Some(quote! { #ident: #default_fn(), }),
Direction::Downgrade => None,
},
// The field doesn't exist in the next version anymore. As such, it is dropped
// during upgrades and needs to be initialized during downgrades.
// TODO: Track the value of removed fields when experimental_conversion_tracking
// is enabled. Currently, the value is lost during the upgrade.
(
_,
ItemStatus::Removal {
ident, default_fn, ..
},
) => match direction {
Direction::Upgrade => None,
Direction::Downgrade => Some(quote! { #ident: #default_fn(), }),
},
(
_,
ItemStatus::Change {
Expand Down Expand Up @@ -307,8 +323,9 @@ impl VersionedField {
let changes = self.changes.as_ref()?;

match direction {
// This arm is only relevant for removed fields which are currently
// not supported.
// This arm is only relevant for removed fields, which are currently
// not tracked.
// TODO: Track the value of removed fields.
Direction::Upgrade => None,

// When we generate code for a downgrade, any changes which need to
Expand Down
8 changes: 7 additions & 1 deletion crates/stackable-versioned-macros/src/codegen/item/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ pub enum ItemStatus {
ident: IdentString,
ty: Box<Type>,
},
Removal {
/// The ident of the item in the last version it is present in.
ident: IdentString,
default_fn: Path,
},
NotPresent,
}

Expand All @@ -78,7 +83,8 @@ impl ItemStatus {
..
}
| Self::Deprecation { ident, .. }
| Self::NoChange { ident, .. } => ident,
| Self::NoChange { ident, .. }
| Self::Removal { ident, .. } => ident,
Self::NotPresent => unreachable!("ItemStatus::NotPresent does not have an ident"),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ impl VersionedVariant {
#ident #fields,
})
}
ItemStatus::NotPresent => None,
// Removing variants is rejected during validation.
ItemStatus::Removal { .. } | ItemStatus::NotPresent => None,
},
None => {
// If there is no chain of variant actions, the variant is not
Expand Down
Loading
Loading