ENT-1712 Warning on cross-reference between JPA entities from different mapped schemas (#3282)

At startup node warns about any MappedSchema containing a JPA entity referencing to another JPA entity from a different MappedSchema. Cross reference between JPA entities across different MappedSchema objects can cause operational issues while evolving one MappedSchema object or migrating it's data. Doc API: Persistence documentation no longer suggests mapping between different schemas.
This commit is contained in:
szymonsztuka 2018-06-08 10:25:50 +01:00 committed by GitHub
parent b9bf624e7a
commit baa6479575
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 341 additions and 4 deletions

View File

@ -90,4 +90,44 @@ data class PersistentStateRef(
/**
* Marker interface to denote a persistable Corda state entity that will always have a transaction id and index
*/
interface StatePersistable : Serializable
interface StatePersistable : Serializable
object MappedSchemaValidator {
fun fieldsFromOtherMappedSchema(schema: MappedSchema) : List<SchemaCrossReferenceReport> =
schema.mappedTypes.map { entity ->
entity.declaredFields.filter { field ->
field.type.enclosingClass != null
&& MappedSchema::class.java.isAssignableFrom(field.type.enclosingClass)
&& hasJpaAnnotation(field.declaredAnnotations)
&& field.type.enclosingClass != schema.javaClass
}.map { field -> SchemaCrossReferenceReport(schema.javaClass.name, entity.simpleName, field.type.enclosingClass.name, field.name, field.type.simpleName)}
}.flatMap { it.toSet() }
fun methodsFromOtherMappedSchema(schema: MappedSchema) : List<SchemaCrossReferenceReport> =
schema.mappedTypes.map { entity ->
entity.declaredMethods.filter { method ->
method.returnType.enclosingClass != null
&& MappedSchema::class.java.isAssignableFrom(method.returnType.enclosingClass)
&& method.returnType.enclosingClass != schema.javaClass
&& hasJpaAnnotation(method.declaredAnnotations)
}.map { method -> SchemaCrossReferenceReport(schema.javaClass.name, entity.simpleName, method.returnType.enclosingClass.name, method.name, method.returnType.simpleName)}
}.flatMap { it.toSet() }
fun crossReferencesToOtherMappedSchema(schema: MappedSchema) : List<SchemaCrossReferenceReport> =
fieldsFromOtherMappedSchema(schema) + methodsFromOtherMappedSchema(schema)
/** Returns true if [javax.persistence] annotation expect [javax.persistence.Transient] is found. */
private inline fun hasJpaAnnotation(annotations: Array<Annotation>) =
annotations.any { annotation -> annotation.toString().startsWith("@javax.persistence.") && annotation !is javax.persistence.Transient }
class SchemaCrossReferenceReport(private val schema: String, private val entity: String, private val referencedSchema: String,
private val fieldOrMethod: String, private val fieldOrMethodType: String) {
override fun toString() = "Cross-reference between MappedSchemas '$schema' and '$referencedSchema'. " +
"MappedSchema '${schema.substringAfterLast(".")}' entity '$entity' field '$fieldOrMethod' is of type '$fieldOrMethodType' " +
"defined in another MappedSchema '${referencedSchema.substringAfterLast(".")}'."
fun toWarning() = toString() + " This may cause issues when evolving MappedSchema or migrating its data, " +
"ensure JPA entities are defined within the same enclosing MappedSchema."
}
}

View File

@ -0,0 +1,37 @@
package net.corda.core.schemas;
import javax.persistence.*;
import java.util.Arrays;
public class BadSchemaJavaV1 extends MappedSchema {
public BadSchemaJavaV1() {
super(TestJavaSchemaFamily.class, 1, Arrays.asList(State.class));
}
@Entity
public static class State extends PersistentState {
private String id;
private GoodSchemaJavaV1.State other;
@Column
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
@JoinColumns({@JoinColumn(name = "itid"), @JoinColumn(name = "outid")})
@OneToOne
@MapsId
public GoodSchemaJavaV1.State getOther() {
return other;
}
public void setOther(GoodSchemaJavaV1.State other) {
this.other = other;
}
}
}

View File

@ -0,0 +1,29 @@
package net.corda.core.schemas;
import javax.persistence.*;
import java.util.Arrays;
public class BadSchemaNoGetterJavaV1 extends MappedSchema {
public BadSchemaNoGetterJavaV1() {
super(TestJavaSchemaFamily.class, 1, Arrays.asList(State.class));
}
@Entity
public static class State extends PersistentState {
@JoinColumns({@JoinColumn(name = "itid"), @JoinColumn(name = "outid")})
@OneToOne
@MapsId
public GoodSchemaJavaV1.State other;
private String id;
@Column
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}
}

View File

@ -0,0 +1,26 @@
package net.corda.core.schemas;
import javax.persistence.Column;
import javax.persistence.Entity;
import java.util.Arrays;
public class GoodSchemaJavaV1 extends MappedSchema {
public GoodSchemaJavaV1() {
super(TestJavaSchemaFamily.class, 1, Arrays.asList(State.class));
}
@Entity
public static class State extends PersistentState {
private String id;
@Column
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}
}

View File

@ -0,0 +1,37 @@
package net.corda.core.schemas;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Transient;
import java.util.Arrays;
public class PoliteSchemaJavaV1 extends MappedSchema {
public PoliteSchemaJavaV1() {
super(TestJavaSchemaFamily.class, 1, Arrays.asList(State.class));
}
@Entity
public static class State extends PersistentState {
private String id;
private GoodSchemaJavaV1.State other;
@Column
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
@Transient
public GoodSchemaJavaV1.State getOther() {
return other;
}
public void setOther(GoodSchemaJavaV1.State other) {
this.other = other;
}
}
}

View File

@ -0,0 +1,4 @@
package net.corda.core.schemas;
public class TestJavaSchemaFamily {
}

View File

@ -0,0 +1,36 @@
package net.corda.core.schemas;
import javax.persistence.Column;
import javax.persistence.Entity;
import java.util.Arrays;
public class TrickySchemaJavaV1 extends MappedSchema {
public TrickySchemaJavaV1() {
super(TestJavaSchemaFamily.class, 1, Arrays.asList(TrickySchemaJavaV1.State.class));
}
@Entity
public static class State extends PersistentState {
private String id;
private GoodSchemaJavaV1.State other;
@Column
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
//the field is a cross-reference to other MappedSchema however the field is not persistent (no JPA annotation)
public GoodSchemaJavaV1.State getOther() {
return other;
}
public void setOther(GoodSchemaJavaV1.State other) {
this.other = other;
}
}
}

View File

@ -0,0 +1,108 @@
package net.corda.core.schemas
import net.corda.core.schemas.MappedSchemaValidator.fieldsFromOtherMappedSchema
import net.corda.core.schemas.MappedSchemaValidator.methodsFromOtherMappedSchema
import net.corda.finance.schemas.CashSchema
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import javax.persistence.*
class MappedSchemasCrossReferenceDetectionTests {
object GoodSchema : MappedSchema(schemaFamily = CashSchema.javaClass, version = 1, mappedTypes = listOf(State::class.java)) {
@Entity
class State(
@Column
var id: String
) : PersistentState()
}
object BadSchema : MappedSchema(schemaFamily = CashSchema.javaClass, version = 1, mappedTypes = listOf(State::class.java)) {
@Entity
class State(
@Column
var id: String,
@JoinColumns(JoinColumn(name = "itid"), JoinColumn(name = "outid"))
@OneToOne
@MapsId
var other: GoodSchema.State
) : PersistentState()
}
object TrickySchema : MappedSchema(schemaFamily = CashSchema.javaClass, version = 1, mappedTypes = listOf(State::class.java)) {
@Entity
class State(
@Column
var id: String,
//the field is a cross-reference to other MappedSchema however the field is not persistent (no JPA annotation)
var other: GoodSchema.State
) : PersistentState()
}
object PoliteSchema : MappedSchema(schemaFamily = CashSchema.javaClass, version = 1, mappedTypes = listOf(State::class.java)) {
@Entity
class State(
@Column
var id: String,
@Transient
var other: GoodSchema.State
) : PersistentState()
}
@Test
fun `no cross reference to other schema`() {
assertThat(fieldsFromOtherMappedSchema(GoodSchema)).isEmpty()
assertThat(methodsFromOtherMappedSchema(GoodSchema)).isEmpty()
}
@Test
fun `cross reference to other schema is detected`() {
assertThat(fieldsFromOtherMappedSchema(BadSchema)).isNotEmpty
assertThat(methodsFromOtherMappedSchema(BadSchema)).isEmpty()
}
@Test
fun `cross reference via non JPA field is allowed`() {
assertThat(fieldsFromOtherMappedSchema(TrickySchema)).isEmpty()
assertThat(methodsFromOtherMappedSchema(TrickySchema)).isEmpty()
}
@Test
fun `cross reference via transient field is allowed`() {
assertThat(fieldsFromOtherMappedSchema(PoliteSchema)).isEmpty()
assertThat(methodsFromOtherMappedSchema(PoliteSchema)).isEmpty()
}
@Test
fun `no cross reference to other schema java`() {
assertThat(fieldsFromOtherMappedSchema(GoodSchemaJavaV1())).isEmpty()
assertThat(methodsFromOtherMappedSchema(GoodSchemaJavaV1())).isEmpty()
}
@Test
fun `cross reference to other schema is detected java`() {
assertThat(fieldsFromOtherMappedSchema(BadSchemaJavaV1())).isEmpty()
assertThat(methodsFromOtherMappedSchema(BadSchemaJavaV1())).isNotEmpty
}
@Test
fun `cross reference to other schema via field is detected java`() {
assertThat(fieldsFromOtherMappedSchema(BadSchemaNoGetterJavaV1())).isNotEmpty
assertThat(methodsFromOtherMappedSchema(BadSchemaNoGetterJavaV1())).isEmpty()
}
@Test
fun `cross reference via non JPA field is allowed java`() {
assertThat(fieldsFromOtherMappedSchema(TrickySchemaJavaV1())).isEmpty()
assertThat(methodsFromOtherMappedSchema(TrickySchemaJavaV1())).isEmpty()
}
@Test
fun `cross reference via transient field is allowed java`() {
assertThat(fieldsFromOtherMappedSchema(PoliteSchemaJavaV1())).isEmpty()
assertThat(methodsFromOtherMappedSchema(PoliteSchemaJavaV1())).isEmpty()
}
}

View File

@ -57,11 +57,15 @@ integration points and not necessarily with every upgrade to the contract code.
``MappedSchema`` offered by a ``QueryableState``, automatically upgrade to a later version of a schema or even
provide a ``MappedSchema`` not originally offered by the ``QueryableState``.
It is expected that multiple different contract state implementations might provide mappings to some common schema.
For example an Interest Rate Swap contract and an Equity OTC Option contract might both provide a mapping to a common
Derivative schema. The schemas should typically not be part of the contract itself and should exist independently of it
It is expected that multiple different contract state implementations might provide mappings within a single schema.
For example an Interest Rate Swap contract and an Equity OTC Option contract might both provide a mapping to
a Derivative contract within the same schema. The schemas should typically not be part of the contract itself and should exist independently
to encourage re-use of a common set within a particular business area or Cordapp.
.. note:: It's advisable to avoid cross-references between different schemas as this may cause issues when evolving ``MappedSchema``
or migrating its data. At startup, nodes log such violations as warnings stating that there's a cross-reference between ``MappedSchema``'s.
The detailed messages incorporate information about what schemas, entities and fields are involved.
``MappedSchema`` offer a family name that is disambiguated using Java package style name-spacing derived from the
class name of a *schema family* class that is constant across versions, allowing the ``SchemaService`` to select a
preferred version of a schema.

View File

@ -132,6 +132,10 @@ Unreleased
* Table name with a typo changed from ``NODE_ATTCHMENTS_CONTRACTS`` to ``NODE_ATTACHMENTS_CONTRACTS``.
* Node logs a warning for any ``MappedSchema`` containing a JPA entity referencing another JPA entity from a different ``MappedSchema`.
The log entry starts with `Cross-reference between MappedSchemas.`.
API: Persistence documentation no longer suggests mapping between different schemas.
.. _changelog_v3.1:
Version 3.1

View File

@ -232,6 +232,11 @@ abstract class AbstractNode(val configuration: NodeConfiguration,
initCertificate()
initialiseJVMAgents()
val schemaService = NodeSchemaService(cordappLoader.cordappSchemas, configuration.notary != null)
schemaService.mappedSchemasWarnings().forEach {
val warning = it.toWarning()
log.warn(warning)
Node.printWarning(warning)
}
val (identity, identityKeyPair) = obtainIdentity(notaryConfig = null)
// Wrapped in an atomic reference just to allow setting it before the closure below gets invoked.

View File

@ -7,6 +7,8 @@ import net.corda.core.schemas.CommonSchemaV1
import net.corda.core.schemas.MappedSchema
import net.corda.core.schemas.PersistentState
import net.corda.core.schemas.QueryableState
import net.corda.core.schemas.*
import net.corda.core.schemas.MappedSchemaValidator.crossReferencesToOtherMappedSchema
import net.corda.core.serialization.SingletonSerializeAsToken
import net.corda.node.internal.schemas.NodeInfoSchemaV1
import net.corda.node.services.api.SchemaService
@ -91,4 +93,9 @@ class NodeSchemaService(extraSchemas: Set<MappedSchema> = emptySet(), includeNot
return VaultSchemaV1.VaultFungibleStates(state.owner, state.amount.quantity, state.amount.token.issuer.party, state.amount.token.issuer.reference, state.participants)
return (state as QueryableState).generateMappedObject(schema)
}
/** Returns list of [MappedSchemaValidator.SchemaCrossReferenceReport] violations. */
fun mappedSchemasWarnings(): List<MappedSchemaValidator.SchemaCrossReferenceReport> =
schemaOptions.keys.map { schema -> crossReferencesToOtherMappedSchema(schema) }.flatMap { it.toList() }
}