Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom implementations for toString, equals and hashCode #1480

Merged
merged 7 commits into from
Aug 16, 2023
Merged
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
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
## 1.11.0-SNAPSHOT (YYYY-MM-DD)

### Breaking Changes
* None.
* `BaseRealmObject.equals()` has changed from being identity-based only (===) to instead return `true` if two objects come from the same Realm version. This e.g means that reading the same object property twice will now be identical. Note, two Realm objects, even with identical values will not be considered equal if they belong to different versions.

```
val childA: Child = realm.query<Child>().first().find()!!
val childB: Child = realm.query<Child>().first().find()!!

// This behavior is the same both before 1.11.0 and before
childA === childB // false

// This will return true in 1.11.0 and onwards. Before it will return false
childA == childB

realm.writeBlocking { /* Do a write */ }
val childC = realm.query<Child>().first().find()!!

// This will return false because childA belong to version 1, while childC belong to version 2.
// Override equals/hashCode if value semantics are wanted.
childA == childC
```

### Enhancements
* Realm model classes now generate custom `toString`, `equals` and `hashCode` implementations. This makes it possible to compare by object reference across multiple collections. Note that two objects at different versions will not be considered equal, even
if the content is the same. Custom implementations of these methods will be respected if they are present. (Issue [#1097](https://github.com/realm/realm-kotlin/issues/1097))
* Support for performing geospatial queries using the new classes: `GeoPoint`, `GeoCircle`, `GeoBox`, and `GeoPolygon`. See `GeoPoint` documentation on how to persist locations. (Issue [#1403](https://github.com/realm/realm-kotlin/pull/1403))
* [Sync] Add support for customizing authorization headers and adding additional custom headers to all Atlas App service requests with `AppConfiguration.Builder.authorizationHeaderName()` and `AppConfiguration.Builder.addCustomRequestHeader(...)`. (Issue [#1453](https://github.com/realm/realm-kotlin/pull/1453))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@
package io.realm.kotlin.internal

import io.realm.kotlin.UpdatePolicy
import io.realm.kotlin.VersionId
import io.realm.kotlin.dynamic.DynamicMutableRealmObject
import io.realm.kotlin.dynamic.DynamicRealmObject
import io.realm.kotlin.ext.asRealmObject
import io.realm.kotlin.ext.isManaged
import io.realm.kotlin.ext.isValid
import io.realm.kotlin.ext.toRealmDictionary
import io.realm.kotlin.ext.toRealmList
import io.realm.kotlin.ext.toRealmSet
import io.realm.kotlin.internal.dynamic.DynamicUnmanagedRealmObject
import io.realm.kotlin.internal.interop.ClassKey
import io.realm.kotlin.internal.interop.CollectionType
import io.realm.kotlin.internal.interop.MemAllocator
import io.realm.kotlin.internal.interop.ObjectKey
import io.realm.kotlin.internal.interop.PropertyKey
import io.realm.kotlin.internal.interop.PropertyType
import io.realm.kotlin.internal.interop.RealmInterop
Expand All @@ -39,6 +43,7 @@ import io.realm.kotlin.internal.interop.RealmValue
import io.realm.kotlin.internal.interop.Timestamp
import io.realm.kotlin.internal.interop.getterScope
import io.realm.kotlin.internal.interop.inputScope
import io.realm.kotlin.internal.platform.identityHashCode
import io.realm.kotlin.internal.platform.realmObjectCompanionOrThrow
import io.realm.kotlin.internal.schema.ClassMetadata
import io.realm.kotlin.internal.schema.PropertyMetadata
Expand Down Expand Up @@ -1145,6 +1150,66 @@ internal object RealmObjectHelper {
}
}

@Suppress("unused") // Called from generated code
// Inlining this functions somehow break the IntelliJ debugger, unclear why?
internal fun realmToString(obj: BaseRealmObject): String {
rorbech marked this conversation as resolved.
Show resolved Hide resolved
// This code assumes no race conditions
val schemaName = obj::class.realmObjectCompanionOrNull()?.io_realm_kotlin_className
val fqName = obj::class.qualifiedName
return obj.realmObjectReference?.let {
if (obj.isValid()) {
val id: RealmObjectIdentifier = obj.getIdentifier()
val objKey = id.objectKey.key
val version = id.versionId.version
"$fqName{state=VALID, schemaName=$schemaName, objKey=$objKey, version=$version, realm=${it.owner.owner.configuration.name}}"
} else {
val state = if (it.owner.isClosed()) {
"CLOSED"
} else {
"INVALID"
}
"$fqName{state=$state, schemaName=$schemaName, realm=${it.owner.owner.configuration.name}, hashCode=${obj.hashCode()}}"
}
} ?: "$fqName{state=UNMANAGED, schemaName=$schemaName, hashCode=${obj.hashCode()}}"
}

@Suppress("unused", "ReturnCount") // Called from generated code
// Inlining this functions somehow break the IntelliJ debugger, unclear why?
internal fun realmEquals(obj: BaseRealmObject, other: Any?): Boolean {
if (obj === other) return true
if (other == null || obj::class != other::class) return false

other as BaseRealmObject

if (other.isManaged()) {
if (obj.isValid() != other.isValid()) return false
return obj.getIdentifierOrNull() == other.getIdentifierOrNull()
} else {
// If one of the objects are unmanaged, they are only equal if identical, which
// should have been caught at the top of this function.
return false
}
}

@Suppress("unused", "MagicNumber") // Called from generated code
// Inlining this functions somehow break the IntelliJ debugger, unclear why?
internal fun realmHashCode(obj: BaseRealmObject): Int {
// This code assumes no race conditions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we put some notes about the side effects of this: Not thread safe on live objects or something like that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably should, but not sure where it would make sense?

return obj.realmObjectReference?.let {
val isValid: Boolean = obj.isValid()
val identifier: RealmObjectIdentifier = if (it.isClosed()) {
RealmObjectIdentifier(ClassKey(-1), ObjectKey(-1), VersionId(0), "")
} else {
obj.getIdentifier()
}
val realmPath: String = it.owner.owner.configuration.path
var hashCode = isValid.hashCode()
hashCode = 31 * hashCode + identifier.hashCode()
hashCode = 31 * hashCode + realmPath.hashCode()
hashCode
} ?: identityHashCode(obj)
}

private fun checkPropertyType(
obj: RealmObjectReference<out BaseRealmObject>,
propertyName: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,15 @@ internal fun BaseRealmObject.getIdentifier(): RealmObjectIdentifier {
val classKey: ClassKey = metadata.classKey
val objKey: ObjectKey = RealmInterop.realm_object_get_key(objectPointer)
val version: VersionId = version()
return Triple(classKey, objKey, version)
val path: String = owner.owner.configuration.path
return RealmObjectIdentifier(classKey, objKey, version, path)
} ?: throw IllegalStateException("Identifier can only be calculated for managed objects.")
ULong
}

public fun BaseRealmObject.getIdentifierOrNull(): RealmObjectIdentifier? {
return runIfManaged {
getIdentifier()
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,15 @@ import kotlin.reflect.KProperty1
// `equals` method, which in general just is the memory address of the object.
internal typealias UnmanagedToManagedObjectCache = MutableMap<BaseRealmObject, BaseRealmObject> // Map<OriginalUnmanagedObject, CachedManagedObject>

// For managed realm objects we use `<ClassKey, ObjectKey, Version>` as a unique identifier
// For managed realm objects we use `<ClassKey, ObjectKey, Version, Path>` as a unique identifier
// We are using a hash on the Kotlin side so we can use a HashMap for O(1) lookup rather than
// having to do O(n) filter with a JNI call for `realm_equals` for each element.
internal typealias RealmObjectIdentifier = Triple<ClassKey, ObjectKey, VersionId>
public data class RealmObjectIdentifier(
val classKey: ClassKey,
val objectKey: ObjectKey,
val versionId: VersionId,
val path: String
)
internal typealias ManagedToUnmanagedObjectCache = MutableMap<RealmObjectIdentifier, BaseRealmObject>

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,8 @@ public expect fun <K : Any?, V : Any?> returnType(field: KMutableProperty1<K, V>
* Returns whether or not we are running on Windows
*/
public expect fun isWindows(): Boolean

/**
* Returns the identity hashcode for a given object.
*/
internal expect fun identityHashCode(obj: Any?): Int
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,5 @@ private fun preparePath(directoryPath: String) {
}

public actual fun isWindows(): Boolean = OS_NAME.contains("windows", ignoreCase = true)

internal actual fun identityHashCode(obj: Any?): Int = System.identityHashCode(obj)
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import platform.Foundation.dataWithContentsOfFile
import platform.Foundation.timeIntervalSince1970
import platform.posix.memcpy
import platform.posix.pthread_threadid_np
import kotlin.native.identityHashCode
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KType

Expand Down Expand Up @@ -192,3 +193,5 @@ private fun NSData.toByteArray(): ByteArray = ByteArray([email protected].
}

public actual fun isWindows(): Boolean = false

internal actual fun identityHashCode(obj: Any?): Int = obj.identityHashCode()
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package io.realm.kotlin.compiler

import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
import org.jetbrains.kotlin.ir.builders.irGet
import org.jetbrains.kotlin.ir.builders.irGetObject
import org.jetbrains.kotlin.ir.builders.irReturn
import org.jetbrains.kotlin.ir.declarations.IrClass
import org.jetbrains.kotlin.ir.declarations.IrProperty
import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction
import org.jetbrains.kotlin.ir.expressions.impl.IrCallImpl
import org.jetbrains.kotlin.ir.types.IrType
import org.jetbrains.kotlin.ir.util.functions
import org.jetbrains.kotlin.name.Name

/**
* Class responsible for adding Realm specific logic to the default object methods like:
* - toString()
* - hashCode()
* - equals()
*
* WARNING: The current logic in here does not work well with incremental compilation. The reason
rorbech marked this conversation as resolved.
Show resolved Hide resolved
* is that we check if these methods are "empty" before filling them out, and during incremental
* compilation they already have content, and since all of these methods are using inlined
* methods they will not pick up changes in the RealmObjectHelper.
*
* This should only impact us as SDK developers though, but it does mean that changes to
* RealmObjectHelper methods will require a clean build to take effect.
*/
class RealmModelDefaultMethodGeneration(private val pluginContext: IrPluginContext) {
rorbech marked this conversation as resolved.
Show resolved Hide resolved

private val realmObjectHelper: IrClass = pluginContext.lookupClassOrThrow(FqNames.REALM_OBJECT_HELPER)
private val realmToString: IrSimpleFunction = realmObjectHelper.lookupFunction(Name.identifier("realmToString"))
private val realmEquals: IrSimpleFunction = realmObjectHelper.lookupFunction(Name.identifier("realmEquals"))
private val realmHashCode: IrSimpleFunction = realmObjectHelper.lookupFunction(Name.identifier("realmHashCode"))
private lateinit var objectReferenceProperty: IrProperty
private lateinit var objectReferenceType: IrType

fun addDefaultMethods(irClass: IrClass) {
objectReferenceProperty = irClass.lookupProperty(Names.OBJECT_REFERENCE)
objectReferenceType = objectReferenceProperty.backingField!!.type

if (syntheticMethodExists(irClass, "toString")) {
addToStringMethodBody(irClass)
}
if (syntheticMethodExists(irClass, "hashCode")) {
addHashCodeMethod(irClass)
}
if (syntheticMethodExists(irClass, "equals")) {
addEqualsMethod(irClass)
}
}

/**
* Checks if a synthetic method exists in the given class. Methods in super classes
* are ignored, only methods actually declared in the class will return `true`.
*
* These methods are created by an earlier step by the Realm compiler plugin and are
* recognized by not being fake and having an empty body.ß
*/
private fun syntheticMethodExists(irClass: IrClass, methodName: String): Boolean {
return irClass.functions.firstOrNull {
!it.isFakeOverride && it.body == null && it.name == Name.identifier(methodName)
} != null
}

private fun addEqualsMethod(irClass: IrClass) {
val function: IrSimpleFunction = irClass.symbol.owner.functions.single { it.name.toString() == "equals" }
function.body = pluginContext.blockBody(function.symbol) {
+irReturn(
IrCallImpl(
startOffset = startOffset,
endOffset = endOffset,
type = pluginContext.irBuiltIns.booleanType,
symbol = realmEquals.symbol,
typeArgumentsCount = 0,
valueArgumentsCount = 2
).apply {
dispatchReceiver = irGetObject(realmObjectHelper.symbol)
putValueArgument(0, irGet(function.dispatchReceiverParameter!!.type, function.dispatchReceiverParameter!!.symbol))
putValueArgument(1, irGet(function.valueParameters[0].type, function.valueParameters[0].symbol))
}
)
}
}

private fun addHashCodeMethod(irClass: IrClass) {
val function: IrSimpleFunction = irClass.symbol.owner.functions.single { it.name.toString() == "hashCode" }
function.body = pluginContext.blockBody(function.symbol) {
+irReturn(
IrCallImpl(
startOffset = startOffset,
endOffset = endOffset,
type = pluginContext.irBuiltIns.intType,
symbol = realmHashCode.symbol,
typeArgumentsCount = 0,
valueArgumentsCount = 1
).apply {
dispatchReceiver = irGetObject(realmObjectHelper.symbol)
putValueArgument(0, irGet(function.dispatchReceiverParameter!!.type, function.dispatchReceiverParameter!!.symbol))
}
)
}
}

private fun addToStringMethodBody(irClass: IrClass) {
val function: IrSimpleFunction = irClass.symbol.owner.functions.single { it.name.toString() == "toString" }
function.body = pluginContext.blockBody(function.symbol) {
+irReturn(
IrCallImpl(
startOffset = startOffset,
endOffset = endOffset,
type = pluginContext.irBuiltIns.stringType,
symbol = realmToString.symbol,
typeArgumentsCount = 0,
valueArgumentsCount = 1
).apply {
dispatchReceiver = irGetObject(realmObjectHelper.symbol)
putValueArgument(0, irGet(function.dispatchReceiverParameter!!.type, function.dispatchReceiverParameter!!.symbol))
}
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ private class RealmModelLowering(private val pluginContext: IrPluginContext) : C
// Modify properties accessor to generate custom getter/setter
AccessorModifierIrGeneration(pluginContext).modifyPropertiesAndCollectSchema(irClass)

// Add custom toString, equals and hashCode methods
val methodGenerator = RealmModelDefaultMethodGeneration(pluginContext)
methodGenerator.addDefaultMethods(irClass)

// Add body for synthetic companion methods
val companion = irClass.companionObject() ?: fatalError("RealmObject without companion: ${irClass.kotlinFqName}")
generator.addCompanionFields(irClass, companion, SchemaCollector.properties[irClass])
Expand Down
Loading