diff --git a/src/main/java/com/github/rschmitt/dynamicobject/DynamicObject.java b/src/main/java/com/github/rschmitt/dynamicobject/DynamicObject.java index a244004..a95813f 100644 --- a/src/main/java/com/github/rschmitt/dynamicobject/DynamicObject.java +++ b/src/main/java/com/github/rschmitt/dynamicobject/DynamicObject.java @@ -21,27 +21,27 @@ public interface DynamicObject> extends Map { /** * @return the underlying Clojure map backing this instance. Downcasting the return value of this method to any - * particular Java type (e.g. IPersistentMap) is not guaranteed to work with future versions of Clojure. + * particular Java type (e.g. IPersistentMap) is not guaranteed to work with future versions of Clojure. */ Map getMap(); - + /** * @return the apparent type of this instance. Note that {@code getClass} will return the class of the interface - * proxy and not the interface itself. + * proxy and not the interface itself. */ Class getType(); - + /** * Invokes clojure.pprint/pprint, which writes a pretty-printed representation of the object to the currently bound * value of *out*, which defaults to System.out (stdout). */ void prettyPrint(); - + /** * Like {@link DynamicObject#prettyPrint}, but returns the pretty-printed string instead of writing it to *out*. */ String toFormattedString(); - + /** * Return a copy of this instance with {@code other}'s fields merged in (nulls don't count). If a given field is * present in both instances, the fields in {@code other} will take precedence. @@ -49,7 +49,7 @@ public interface DynamicObject> extends Map { * Equivalent to: {@code (merge-with (fn [a b] (if (nil? b) a b)) this other)} */ D merge(D other); - + /** * Recursively compares this instance with {@code other}, returning a new instance containing all of the common * elements of both {@code this} and {@code other}. Maps and lists are compared recursively; everything else, @@ -58,7 +58,7 @@ public interface DynamicObject> extends Map { * Equivalent to: {@code (nth (clojure.data/diff this other) 2)} */ D intersect(D other); - + /** * Recursively compares this instance with {@code other}, similar to {@link #intersect}, but returning the fields that * are unique to {@code this}. Uses the same recursion strategy as {@code intersect}. @@ -66,14 +66,14 @@ public interface DynamicObject> extends Map { * Equivalent to: {@code (nth (clojure.data/diff this other) 0)} */ D subtract(D other); - + /** * Validate that all fields annotated with @Required are non-null, and that all present fields are of the correct * type. Returns the validated instance unchanged, which allows the validate method to be called at the end of a * fluent builder chain. */ D validate(); - + /** * Post-deserialization hook. The intended use of this method is to facilitate format upgrades. For example, if a * new field has been added to a DynamicObject schema, this method can be implemented to add that field (with @@ -85,7 +85,7 @@ public interface DynamicObject> extends Map { */ @Deprecated D afterDeserialization(); - + /** * Serialize the given object to Edn. Any {@code EdnTranslator}s that have been registered through * {@link DynamicObject#registerType} will be invoked as needed. @@ -93,42 +93,42 @@ public interface DynamicObject> extends Map { static String serialize(Object o) { return EdnSerialization.serialize(o); } - + static void serialize(Object o, Writer w) { EdnSerialization.serialize(o, w); } - + /** * Deserializes a DynamicObject or registered type from a String. * - * @param edn The Edn representation of the object. + * @param edn The Edn representation of the object. * @param type The type of class to deserialize. Must be an interface that extends DynamicObject. */ static T deserialize(String edn, Class type) { return EdnSerialization.deserialize(edn, type); } - + /** * Lazily deserialize a stream of top-level Edn elements as the given type. */ static Stream deserializeStream(PushbackReader streamReader, Class type) { return EdnSerialization.deserializeStream(streamReader, type); } - + /** * Serialize a single object {@code o} to binary Fressian data. */ static byte[] toFressianByteArray(Object o) { return FressianSerialization.toFressianByteArray(o); } - + /** * Deserialize and return the Fressian-encoded object in {@code bytes}. */ static T fromFressianByteArray(byte[] bytes) { return FressianSerialization.fromFressianByteArray(bytes); } - + /** * Create a {@link FressianReader} instance to read from {@code is}. The reader will be created with support for all * the basic Java and Clojure types, all DynamicObject types registered by calling {@link #registerTag(Class, @@ -140,7 +140,7 @@ static T fromFressianByteArray(byte[] bytes) { static FressianReader createFressianReader(InputStream is, boolean validateChecksum) { return FressianSerialization.createFressianReader(is, validateChecksum); } - + /** * Create a {@link FressianWriter} instance to write to {@code os}. The writer will be created with support for all * the basic Java and Clojure types, all DynamicObject types registered by calling {@link #registerTag(Class, @@ -151,7 +151,7 @@ static FressianReader createFressianReader(InputStream is, boolean validateCheck static FressianWriter createFressianWriter(OutputStream os) { return FressianSerialization.createFressianWriter(os); } - + /** * Lazily deserialize a stream of Fressian-encoded values as the given type. A Fressian footer, if encountered, will * be validated. @@ -159,21 +159,21 @@ static FressianWriter createFressianWriter(OutputStream os) { static Stream deserializeFressianStream(InputStream is, Class type) { return FressianSerialization.deserializeFressianStream(is, type); } - + /** * Use the supplied {@code map} to back an instance of {@code type}. */ static > D wrap(Map map, Class type) { return Instances.wrap(map, type); } - + /** * Create a "blank" instance of {@code type}, backed by an empty Clojure map. All fields will be null. */ static > D newInstance(Class type) { return Instances.newInstance(type); } - + /** * Register an {@link EdnTranslator} to enable instances of {@code type} to be serialized to and deserialized from * Edn using reader tags. @@ -181,7 +181,7 @@ static > D newInstance(Class type) { static void registerType(Class type, EdnTranslator translator) { EdnSerialization.registerType(type, translator); } - + /** * Register a {@link org.fressian.handlers.ReadHandler} and {@link org.fressian.handlers.WriteHandler} to enable * instances of {@code type} to be serialized to and deserialized from Fressian data. @@ -189,7 +189,7 @@ static void registerType(Class type, EdnTranslator translator) { static void registerType(Class type, String tag, ReadHandler readHandler, WriteHandler writeHandler) { FressianSerialization.registerType(type, tag, readHandler, writeHandler); } - + /** * Deregister the given {@code translator}. After this method is invoked, it will no longer be possible to read or * write instances of {@code type} unless another translator is registered. @@ -197,7 +197,7 @@ static void registerType(Class type, String tag, ReadHandler readHandler, WriteH static void deregisterType(Class type) { Serialization.deregisterType(type); } - + /** * Register a reader tag for a DynamicObject type. This is useful for reading Edn representations of Clojure * records. @@ -205,14 +205,14 @@ static void deregisterType(Class type) { static > void registerTag(Class type, String tag) { Serialization.registerTag(type, tag); } - + /** * Deregister the reader tag for the given DynamicObject type. */ static > void deregisterTag(Class type) { Serialization.deregisterTag(type); } - + /** * Specify a default reader, which is a function that will be called when any unknown reader tags are encountered. * The function will be passed the reader tag (as a string) and the tagged Edn element, and can return whatever it diff --git a/src/main/java/com/github/rschmitt/dynamicobject/internal/InvokeDynamicInvocationHandler.java b/src/main/java/com/github/rschmitt/dynamicobject/internal/InvokeDynamicInvocationHandler.java index c11e319..40ecf97 100644 --- a/src/main/java/com/github/rschmitt/dynamicobject/internal/InvokeDynamicInvocationHandler.java +++ b/src/main/java/com/github/rschmitt/dynamicobject/internal/InvokeDynamicInvocationHandler.java @@ -16,39 +16,50 @@ public class InvokeDynamicInvocationHandler implements DynamicInvocationHandler { private final Class dynamicObjectType; - + public InvokeDynamicInvocationHandler(Class dynamicObjectType) { this.dynamicObjectType = dynamicObjectType; } - + @Override @SuppressWarnings("unchecked") - public CallSite handleInvocation( - MethodHandles.Lookup lookup, - String methodName, - MethodType methodType, - MethodHandle superMethod - ) throws Throwable { + public CallSite handleInvocation(MethodHandles.Lookup lookup, String methodName, MethodType methodType, MethodHandle superMethod) throws Throwable { Class proxyType = methodType.parameterArray()[0]; - MethodHandle mh; - if (superMethod != null && !"validate".equals(methodName)) { + MethodHandle mh = null; + if (superMethod != null && !"validate".equals(methodName) && !"equals".equals(methodName) && !"hashCode".equals(methodName)) { mh = superMethod.asType(methodType); return new ConstantCallSite(mh); } - if ("validate".equals(methodName)) { - mh = lookup.findSpecial(DynamicObjectInstance.class, "$$validate", methodType(Object.class, new Class[]{}), proxyType).asType(methodType); + if ("equals".equals(methodName)) { + try { + mh = lookup.findSpecial(dynamicObjectType, "isEqualTo", methodType(Boolean.class, new Class[] { Object.class }), proxyType).asType(methodType); + } catch (NoSuchMethodException ex) { + mh = superMethod.asType(methodType); + return new ConstantCallSite(mh); + } + } else if ("hashCode".equals(methodName)) { + + try { + mh = lookup.findSpecial(dynamicObjectType, "getHashCode", methodType(Integer.class, new Class[] {}), proxyType).asType(methodType); + } catch (NoSuchMethodException ex) { + mh = superMethod.asType(methodType); + return new ConstantCallSite(mh); + } + + } else if ("validate".equals(methodName)) { + mh = lookup.findSpecial(DynamicObjectInstance.class, "$$validate", methodType(Object.class, new Class[] {}), proxyType).asType(methodType); } else if ("$$customValidate".equals(methodName)) { try { mh = lookup.findSpecial(dynamicObjectType, "validate", methodType(dynamicObjectType), proxyType); } catch (NoSuchMethodException ex) { - mh = lookup.findSpecial(DynamicObjectInstance.class, "$$noop", methodType(Object.class, new Class[]{}), proxyType); + mh = lookup.findSpecial(DynamicObjectInstance.class, "$$noop", methodType(Object.class, new Class[] {}), proxyType); } mh = mh.asType(methodType); } else if ("afterDeserialization".equals(methodName)) { - mh = lookup.findSpecial(DynamicObjectInstance.class, "$$noop", methodType(Object.class, new Class[]{}), proxyType).asType(methodType); + mh = lookup.findSpecial(DynamicObjectInstance.class, "$$noop", methodType(Object.class, new Class[] {}), proxyType).asType(methodType); } else { Method method = dynamicObjectType.getMethod(methodName, methodType.dropParameterTypes(0, 1).parameterArray()); - + if (isBuilderMethod(method)) { Object key = Reflection.getKeyForBuilder(method); if (Reflection.isMetadataBuilder(method)) { @@ -77,7 +88,7 @@ public CallSite handleInvocation( } return new ConstantCallSite(mh); } - + private boolean isBuilderMethod(Method method) { return method.getReturnType().equals(dynamicObjectType) && method.getParameterCount() == 1; } diff --git a/src/test/java/com/github/rschmitt/dynamicobject/EqualityTest.java b/src/test/java/com/github/rschmitt/dynamicobject/EqualityTest.java new file mode 100644 index 0000000..59dd048 --- /dev/null +++ b/src/test/java/com/github/rschmitt/dynamicobject/EqualityTest.java @@ -0,0 +1,102 @@ +package com.github.rschmitt.dynamicobject; + +import static com.github.rschmitt.dynamicobject.DynamicObject.deserialize; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.junit.Test; + +public class EqualityTest { + @Test + public void customEqualityTest() { + String edn1 = "{:firstName \"Tom\",:lastName \"Brady\",:ssn \"123456789\" }"; + String edn2 = "{:firstName \"Thomas\",:lastName \"Brady\",:ssn \"123456789\" }"; + Person person1 = deserialize(edn1, Person.class); + Person person2 = deserialize(edn2, Person.class); + assertEquals(person1, person2); + + Set people = new HashSet<>(Arrays.asList(person1, person2)); + assertTrue(people.contains(person1)); + } + + @Test + public void regualarEqualityTest() { + String edn1 = "{:accountType \"Credit Card\",:name \"Visa\",:number \"123456789\" }"; + String edn2 = "{:accountType \"Credit Card\",:name \"Master Card\",:number \"123456789\" }"; + Account account1 = deserialize(edn1, Account.class); + Account account2 = deserialize(edn2, Account.class); + assertNotEquals(account1, account2); + + String edn3 = "{:accountType \"Credit Card\",:name \"Visa\",:number \"123456789\" }"; + String edn4 = "{:accountType \"Credit Card\",:name \"Visa\",:number \"123456789\" }"; + Account account3 = deserialize(edn3, Account.class); + Account account4 = deserialize(edn4, Account.class); + assertEquals(account3, account4); + + Set accounts = new HashSet<>(Arrays.asList(account1, account2)); + assertTrue(accounts.contains(account1)); + } + + public interface Person extends DynamicObject { + @Key(":firstName") + String getFirstName(); + + @Key(":firstName") + Person withFirstName(String firstName); + + @Key(":lastName") + String getLastName(); + + @Key(":lastName") + Person withLastName(String lastName); + + @Key(":ssn") + String getSsn(); + + @Key(":ssn") + Person withSsn(String ssn); + + default Boolean isEqualTo(Object other) { + if (other == this) + return true; + if (other == null) + return false; + if (other instanceof Person) { + Person otherPerson = (Person) other; + return this.getSsn().equals(otherPerson.getSsn()); + } else { + return equals(other); + } + } + + default Integer getHashCode() { + return getSsn().hashCode(); + } + + } + + public interface Account extends DynamicObject { + @Key(":accountType") + String getAccountType(); + + @Key(":accountType") + Account withAccountType(String accountType); + + @Key(":name") + String getName(); + + @Key(":name") + Account withName(String name); + + @Key(":number") + String getAccountNumber(); + + @Key(":number") + Account withAccountNumber(String number); + } +}