Repository: smithy-lang/smithy
Pull request #2248 (2024-04-18 23:10:33)

Add typed properties for TypedPropertiesBag
#### Background
Adds a strongly typed Key to the typed properties bag used by Symbols in codegen. These changes are intended to improve the experience of using Symbol properties in code generators. Usage of these new typed properties would look like: 

```java
Property<String> myStringProperty = Property.named("string property"); 

Symbol symbol = Symbol.builder()
                .name("foo")
                .putProperty(myStringProperty, "foo")
                .build()
                
String result = symbol.expectProperty(myStringProperty);
```

#### Testing
Added new unit tests

---
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.



Patch:
diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/Property.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/Property.java
new file mode 100644
index 000000000..8ddd65d7c
--- /dev/null
+++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/Property.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.codegen.core;
+
+import java.util.Objects;
+
+/**
+ * A {@code Property} provides an identity-based, immutable token for a property.
+ *
+ * <p>The token also contains a name used to describe the value.
+ */
+public final class Property<T> {
+
+    private final String name;
+
+    /**
+     * @param name Name of the value.
+     */
+    public Property(String name) {
+        this.name = Objects.requireNonNull(name);
+    }
+
+    /**
+     * Create a new identity-based property key.
+     *
+     * @param name Name of the property.
+     * @param <T> value type associated with the property.
+     * @return the created property.
+     */
+    public static <T> Property<T> named(String name) {
+        return new Property<>(name);
+    }
+
+    @Override
+    public String toString() {
+        return name;
+    }
+}
diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/TypedPropertiesBag.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/TypedPropertiesBag.java
index 5b386fd4a..6147d6dd2 100644
--- a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/TypedPropertiesBag.java
+++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/TypedPropertiesBag.java
@@ -15,6 +15,7 @@
 
 package software.amazon.smithy.codegen.core;
 
+import java.util.Iterator;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -23,9 +24,11 @@ import software.amazon.smithy.utils.BuilderRef;
 class TypedPropertiesBag {
 
     private final Map<String, Object> properties;
+    private final Map<Property<?>, Object> typedProperties;
 
     TypedPropertiesBag(Builder<?> bagBuilder) {
         this.properties = bagBuilder.properties.copy();
+        this.typedProperties = bagBuilder.typedProperties.copy();
     }
 
     /**
@@ -37,6 +40,15 @@ class TypedPropertiesBag {
         return properties;
     }
 
+    /**
+     * Gets the additional typed properties of the object.
+     *
+     * @return Returns a map of additional typed properties.
+     */
+    public Iterator<Property<?>> getTypedProperties() {
+        return typedProperties.keySet().iterator();
+    }
+
     /**
      * Gets a specific property if present.
      *
@@ -47,6 +59,18 @@ class TypedPropertiesBag {
         return Optional.ofNullable(properties.get(name));
     }
 
+    /**
+     * Get a typed property if present.
+     *
+     * @param property   property key to get by exact reference identity.
+     * @param <T> value type of the property
+     * @return Returns the optionally found property.
+     */
+    @SuppressWarnings("unchecked")
+    public <T> Optional<T> getProperty(Property<T> property) {
+        return Optional.ofNullable((T) typedProperties.get(property));
+    }
+
     /**
      * Gets an additional property of a specific type.
      *
@@ -97,6 +121,18 @@ class TypedPropertiesBag {
                 "Property `%s` is not part of %s, `%s`", name, getClass().getSimpleName(), this)));
     }
 
+    /**
+     * Get a property and throw if it isn't present.
+     *
+     * @param property property key to get by exact reference identity.
+     * @param <T> value type of the property.
+     * @throws IllegalArgumentException if the property isn't found.
+     */
+    <T> T expectProperty(Property<T> property) {
+        return getProperty(property).orElseThrow(() -> new IllegalArgumentException(String.format(
+                "Property `%s` expected but not found on %s", property, this)));
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) {
@@ -118,6 +154,7 @@ class TypedPropertiesBag {
      */
     abstract static class Builder<T extends Builder<T>> {
         BuilderRef<Map<String, Object>> properties = BuilderRef.forOrderedMap();
+        BuilderRef<Map<Property<?>, Object>> typedProperties = BuilderRef.forOrderedMap();
 
         /**
          * Sets a specific custom property.
@@ -132,6 +169,19 @@ class TypedPropertiesBag {
             return (T) this;
         }
 
+        /**
+         * Sets a specific, typed custom property.
+         *
+         * @param property Key to set.
+         * @param value Value to set.
+         * @return Returns the builder.
+         */
+        @SuppressWarnings("unchecked")
+        public <K> T putProperty(Property<K> property, K value) {
+            typedProperties.get().put(property, value);
+            return (T) this;
+        }
+
         /**
          * Removes a specific custom property.
          *
@@ -145,7 +195,19 @@ class TypedPropertiesBag {
         }
 
         /**
-         * Replaces all of the custom properties.
+         * Removes a specific, typed custom property.
+         *
+         * @param property Property to remove.
+         * @return Returns the builder.
+         */
+        @SuppressWarnings("unchecked")
+        public T removeProperty(Property<?> property) {
+            typedProperties.get().remove(property);
+            return (T) this;
+        }
+
+        /**
+         * Replaces all the custom properties.
          *
          * @param properties Custom properties to replace with.
          * @return Returns the builder.


Test patch:
diff --git a/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/SymbolTest.java b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/SymbolTest.java
index 49b08fcc9..2f969daaf 100644
--- a/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/SymbolTest.java
+++ b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/SymbolTest.java
@@ -19,10 +19,14 @@ import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.in;
 import static org.hamcrest.Matchers.is;
 
+import java.util.Map;
+import java.util.Properties;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import software.amazon.smithy.utils.MapUtils;
 
 public class SymbolTest {
     @Test
@@ -38,7 +42,7 @@ public class SymbolTest {
     }
 
     @Test
-    public void getsTypedProperties() {
+    public void getsPropertiesFromClass() {
         Symbol symbol = Symbol.builder()
                 .name("foo")
                 .putProperty("baz", "bar")
@@ -49,6 +53,21 @@ public class SymbolTest {
         assertThat(symbol.expectProperty("bam", Integer.class), equalTo(100));
     }
 
+    @Test
+    public void getsTypedProperties() {
+        Property<String> stringProperty = Property.named("string");
+        Property<Integer> integerProperty = Property.named("int");
+
+        Symbol symbol = Symbol.builder()
+                .name("foo")
+                .putProperty(stringProperty, "foo")
+                .putProperty(integerProperty, 100)
+                .build();
+
+        assertThat(symbol.expectProperty(stringProperty), equalTo("foo"));
+        assertThat(symbol.expectProperty(integerProperty), equalTo(100));
+    }
+
     @Test
     public void throwsIfExpectedPropertyIsNotPresent() {
         Assertions.assertThrows(IllegalArgumentException.class, () -> {
@@ -58,6 +77,16 @@ public class SymbolTest {
         });
     }
 
+    @Test
+    public void throwsIfExpectedTypedPropertyIsNotPresent() {
+        Assertions.assertThrows(IllegalArgumentException.class, () -> {
+            Property<String> prop = Property.named("prop");
+            Symbol symbol = Symbol.builder().name("foo").build();
+
+            symbol.expectProperty(prop);
+        });
+    }
+
     @Test
     public void throwsIfExpectedPropertyIsNotOfSameType() {
         Assertions.assertThrows(IllegalArgumentException.class, () -> {