From e7cc38cf1bb5bd0263c5ad29f10d6eb718d71681 Mon Sep 17 00:00:00 2001 From: Tudor Malene Date: Fri, 12 Jan 2018 09:53:42 +0000 Subject: [PATCH 1/2] ENT-1330: Liquibase migration generation tool (#339) * [ENT-1330]: Tool to generate migration files for CorDapps * [ENT-1330]: cleanups * [ENT-1330]: cleanups * [ENT-1330]: added test to check if a migration file is properly generated and picked up --- .../net/corda/core/schemas/PersistentTypes.kt | 27 +++-- .../persistence/HibernateConfiguration.kt | 43 ++++--- .../main/kotlin/net/corda/node/ArgsParser.kt | 9 +- .../net/corda/node/internal/NodeStartup.kt | 15 ++- .../services/persistence/MigrationExporter.kt | 114 ++++++++++++++++++ .../kotlin/net/corda/node/ArgsParserTest.kt | 3 +- .../persistence/SchemaMigrationTest.kt | 64 +++++++++- 7 files changed, 241 insertions(+), 34 deletions(-) create mode 100644 node/src/main/kotlin/net/corda/node/services/persistence/MigrationExporter.kt diff --git a/core/src/main/kotlin/net/corda/core/schemas/PersistentTypes.kt b/core/src/main/kotlin/net/corda/core/schemas/PersistentTypes.kt index 3de6f4224b..3df478317b 100644 --- a/core/src/main/kotlin/net/corda/core/schemas/PersistentTypes.kt +++ b/core/src/main/kotlin/net/corda/core/schemas/PersistentTypes.kt @@ -81,23 +81,28 @@ data class PersistentStateRef( interface StatePersistable private const val MIGRATION_PREFIX = "migration" +private const val DEFAULT_MIGRATION_EXTENSION = "xml" +private const val CHANGELOG_NAME = "changelog-master" private val possibleMigrationExtensions = listOf(".xml", ".sql", ".yml", ".json") -fun getMigrationResource(schema: MappedSchema): String? { +fun getMigrationResource(schema: MappedSchema): String? { val declaredMigration = schema.getMigrationResource() if (declaredMigration == null) { - // try to apply a naming convention - // SchemaName will be transformed from camel case to hyphen - // then ".changelog-master" plus one of the supported extensions will be added - val name: String = schema::class.simpleName!! - val fileName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_HYPHEN, name) - val resource = "${MIGRATION_PREFIX}/${fileName}.changelog-master" - val foundResource = possibleMigrationExtensions.map { "${resource}${it}" }.firstOrNull { + // try to apply the naming convention and find the migration file in the classpath + val resource = migrationResourceNameForSchema(schema) + return possibleMigrationExtensions.map { "${resource}${it}" }.firstOrNull { Thread.currentThread().contextClassLoader.getResource(it) != null } - return foundResource - } else { - return "${MIGRATION_PREFIX}/${declaredMigration}.xml" } + + return "${MIGRATION_PREFIX}/${declaredMigration}.${DEFAULT_MIGRATION_EXTENSION}" +} + +// SchemaName will be transformed from camel case to lower_hyphen +// then add ".changelog-master" +fun migrationResourceNameForSchema(schema: MappedSchema): String { + val name: String = schema::class.simpleName!! + val fileName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_HYPHEN, name) + return "${MIGRATION_PREFIX}/${fileName}.${CHANGELOG_NAME}" } \ No newline at end of file diff --git a/node-api/src/main/kotlin/net/corda/nodeapi/internal/persistence/HibernateConfiguration.kt b/node-api/src/main/kotlin/net/corda/nodeapi/internal/persistence/HibernateConfiguration.kt index 336f5298e2..0e17c06e4c 100644 --- a/node-api/src/main/kotlin/net/corda/nodeapi/internal/persistence/HibernateConfiguration.kt +++ b/node-api/src/main/kotlin/net/corda/nodeapi/internal/persistence/HibernateConfiguration.kt @@ -5,6 +5,8 @@ import net.corda.core.schemas.MappedSchema import net.corda.core.utilities.contextLogger import net.corda.core.utilities.toHexString import org.hibernate.SessionFactory +import org.hibernate.boot.Metadata +import org.hibernate.boot.MetadataBuilder import org.hibernate.boot.MetadataSources import org.hibernate.boot.registry.BootstrapServiceRegistryBuilder import org.hibernate.cfg.Configuration @@ -28,6 +30,22 @@ class HibernateConfiguration( ) { companion object { private val logger = contextLogger() + + // register custom converters + fun buildHibernateMetadata(metadataBuilder: MetadataBuilder, jdbcUrl:String, attributeConverters: Collection>): Metadata { + metadataBuilder.run { + attributeConverters.forEach { applyAttributeConverter(it) } + // Register a tweaked version of `org.hibernate.type.MaterializedBlobType` that truncates logged messages. + // to avoid OOM when large blobs might get logged. + applyBasicType(CordaMaterializedBlobType, CordaMaterializedBlobType.name) + applyBasicType(CordaWrapperBinaryType, CordaWrapperBinaryType.name) + // When connecting to SqlServer (and only then) do we need to tell hibernate to use + // nationalised (i.e. Unicode) strings by default + val forceUnicodeForSqlServer = jdbcUrl.contains(":sqlserver:", ignoreCase = true) + enableGlobalNationalizedCharacterDataSupport(forceUnicodeForSqlServer) + return build() + } + } } // TODO: make this a guava cache or similar to limit ability for this to grow forever. @@ -54,7 +72,7 @@ class HibernateConfiguration( //preserving case-sensitive schema name for PostgreSQL by wrapping in double quotes, schema without double quotes would be treated as case-insensitive (lower cases) val schemaName = if (jdbcUrl.contains(":postgresql:", ignoreCase = true) && !databaseConfig.schema.startsWith("\"")) { "\"" + databaseConfig.schema + "\"" - } else { + } else { databaseConfig.schema } config.setProperty("hibernate.default_schema", schemaName) @@ -93,19 +111,8 @@ class HibernateConfiguration( private fun buildSessionFactory(config: Configuration, metadataSources: MetadataSources): SessionFactory { config.standardServiceRegistryBuilder.applySettings(config.properties) - val metadata = metadataSources.getMetadataBuilder(config.standardServiceRegistryBuilder.build()).run { - // register custom converters - attributeConverters.forEach { applyAttributeConverter(it) } - // Register a tweaked version of `org.hibernate.type.MaterializedBlobType` that truncates logged messages. - // to avoid OOM when large blobs might get logged. - applyBasicType(CordaMaterializedBlobType, CordaMaterializedBlobType.name) - applyBasicType(CordaWrapperBinaryType, CordaWrapperBinaryType.name) - // When connecting to SqlServer (and only then) do we need to tell hibernate to use - // nationalised (i.e. Unicode) strings by default - val forceUnicodeForSqlServer = jdbcUrl.contains(":sqlserver:", ignoreCase = true) - enableGlobalNationalizedCharacterDataSupport(forceUnicodeForSqlServer) - build() - } + val metadataBuilder = metadataSources.getMetadataBuilder(config.standardServiceRegistryBuilder.build()) + val metadata = buildHibernateMetadata(metadataBuilder, jdbcUrl, attributeConverters) return metadata.sessionFactoryBuilder.run { allowOutOfTransactionUpdateOperations(true) @@ -140,14 +147,14 @@ class HibernateConfiguration( } // A tweaked version of `org.hibernate.type.MaterializedBlobType` that truncates logged messages. Also logs in hex. - private object CordaMaterializedBlobType : AbstractSingleColumnStandardBasicType(BlobTypeDescriptor.DEFAULT, CordaPrimitiveByteArrayTypeDescriptor) { + object CordaMaterializedBlobType : AbstractSingleColumnStandardBasicType(BlobTypeDescriptor.DEFAULT, CordaPrimitiveByteArrayTypeDescriptor) { override fun getName(): String { return "materialized_blob" } } // A tweaked version of `org.hibernate.type.descriptor.java.PrimitiveByteArrayTypeDescriptor` that truncates logged messages. - private object CordaPrimitiveByteArrayTypeDescriptor : PrimitiveByteArrayTypeDescriptor() { + object CordaPrimitiveByteArrayTypeDescriptor : PrimitiveByteArrayTypeDescriptor() { private val LOG_SIZE_LIMIT = 1024 override fun extractLoggableRepresentation(value: ByteArray?): String { @@ -164,7 +171,7 @@ class HibernateConfiguration( } // A tweaked version of `org.hibernate.type.WrapperBinaryType` that deals with ByteArray (java primitive byte[] type). - private object CordaWrapperBinaryType : AbstractSingleColumnStandardBasicType(VarbinaryTypeDescriptor.INSTANCE, PrimitiveByteArrayTypeDescriptor.INSTANCE) { + object CordaWrapperBinaryType : AbstractSingleColumnStandardBasicType(VarbinaryTypeDescriptor.INSTANCE, PrimitiveByteArrayTypeDescriptor.INSTANCE) { override fun getRegistrationKeys(): Array { return arrayOf(name, "ByteArray", ByteArray::class.java.name) } @@ -176,4 +183,4 @@ class HibernateConfiguration( } /** Allow Oracle database drivers ojdbc7.jar and ojdbc8.jar to deserialize classes from oracle.sql.converter package. */ -fun oracleJdbcDriverSerialFilter(clazz: Class<*>) : Boolean = clazz.name.startsWith("oracle.sql.converter.") +fun oracleJdbcDriverSerialFilter(clazz: Class<*>): Boolean = clazz.name.startsWith("oracle.sql.converter.") diff --git a/node/src/main/kotlin/net/corda/node/ArgsParser.kt b/node/src/main/kotlin/net/corda/node/ArgsParser.kt index 50e3049025..ba38458996 100644 --- a/node/src/main/kotlin/net/corda/node/ArgsParser.kt +++ b/node/src/main/kotlin/net/corda/node/ArgsParser.kt @@ -44,6 +44,9 @@ class ArgsParser { private val justGenerateDatabaseMigrationArg = optionParser .accepts("just-generate-db-migration", "Generate the database migration in the specified output file, and then quit.") .withOptionalArg() + private val justCreateMigrationForCorDappArg = optionParser + .accepts("just-create-migration-cordapp", "Create migration files for a CorDapp") + .withRequiredArg() private val bootstrapRaftClusterArg = optionParser.accepts("bootstrap-raft-cluster", "Bootstraps Raft cluster. The node forms a single node cluster (ignoring otherwise configured peer addresses), acting as a seed for other nodes to join the cluster.") private val helpArg = optionParser.accepts("help").forHelp() @@ -67,9 +70,10 @@ class ArgsParser { Pair(true, optionSet.valueOf(justGenerateDatabaseMigrationArg) ?: "migration${SimpleDateFormat("yyyyMMddHHmmss").format(Date())}.sql") else Pair(false, null) + val createMigrationForCorDapp: String? = optionSet.valueOf(justCreateMigrationForCorDappArg) val bootstrapRaftCluster = optionSet.has(bootstrapRaftClusterArg) return CmdLineOptions(baseDirectory, configFile, help, loggingLevel, logToConsole, isRegistration, isVersion, - noLocalShell, sshdServer, justGenerateNodeInfo, justRunDbMigration, generateDatabaseMigrationToFile, bootstrapRaftCluster) + noLocalShell, sshdServer, justGenerateNodeInfo, justRunDbMigration, generateDatabaseMigrationToFile, bootstrapRaftCluster, createMigrationForCorDapp) } fun printHelp(sink: PrintStream) = optionParser.printHelpOn(sink) @@ -87,7 +91,8 @@ data class CmdLineOptions(val baseDirectory: Path, val justGenerateNodeInfo: Boolean, val justRunDbMigration: Boolean, val generateDatabaseMigrationToFile: Pair, - val bootstrapRaftCluster: Boolean) { + val bootstrapRaftCluster: Boolean, + val justCreateMigrationForCorDapp: String?) { fun loadConfig(): NodeConfiguration { val config = ConfigHelper.loadConfig(baseDirectory, configFile).parseAsNodeConfiguration() if (isRegistration) { diff --git a/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt b/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt index aaf202a751..49a37442fb 100644 --- a/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt +++ b/node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt @@ -2,12 +2,16 @@ package net.corda.node.internal import com.jcabi.manifests.Manifests import joptsimple.OptionException -import net.corda.core.internal.* +import net.corda.core.internal.Emoji import net.corda.core.internal.concurrent.thenMatch +import net.corda.core.internal.createDirectories +import net.corda.core.internal.div +import net.corda.core.internal.randomOrNull import net.corda.core.utilities.loggerFor import net.corda.node.* import net.corda.node.services.config.NodeConfiguration import net.corda.node.services.config.NodeConfigurationImpl +import net.corda.node.services.persistence.MigrationExporter import net.corda.node.services.transactions.bftSMaRtSerialFilter import net.corda.node.shell.InteractiveShell import net.corda.node.utilities.registration.HTTPNetworkRegistrationService @@ -137,6 +141,15 @@ open class NodeStartup(val args: Array) { node.generateDatabaseSchema(cmdlineOptions.generateDatabaseMigrationToFile.second!!) return } + if(cmdlineOptions.justCreateMigrationForCorDapp != null){ + try { + MigrationExporter.generateMigrationForCorDapp(cmdlineOptions.justCreateMigrationForCorDapp) + } catch (e: Exception) { + logger.error("Could not generate migration for ${cmdlineOptions.justCreateMigrationForCorDapp}", e) + } + return + } + val startedNode = node.start() Node.printBasicNodeInfo("Loaded CorDapps", startedNode.services.cordappProvider.cordapps.joinToString { it.name }) startedNode.internals.nodeReadyFuture.thenMatch({ diff --git a/node/src/main/kotlin/net/corda/node/services/persistence/MigrationExporter.kt b/node/src/main/kotlin/net/corda/node/services/persistence/MigrationExporter.kt new file mode 100644 index 0000000000..3c4e019bb3 --- /dev/null +++ b/node/src/main/kotlin/net/corda/node/services/persistence/MigrationExporter.kt @@ -0,0 +1,114 @@ +package net.corda.node.services.persistence + +import net.corda.core.identity.AbstractParty +import net.corda.core.schemas.MappedSchema +import net.corda.core.schemas.migrationResourceNameForSchema +import net.corda.nodeapi.internal.persistence.HibernateConfiguration +import org.hibernate.boot.Metadata +import org.hibernate.boot.MetadataSources +import org.hibernate.boot.registry.BootstrapServiceRegistryBuilder +import org.hibernate.cfg.Configuration +import org.hibernate.dialect.Dialect +import org.hibernate.tool.hbm2ddl.SchemaExport +import org.hibernate.tool.schema.TargetType +import java.io.File +import java.nio.file.Path +import java.sql.Types +import java.util.* +import javax.persistence.AttributeConverter +import javax.persistence.Converter + +/** + * This is useful for CorDapp developers who want to enable migrations for + * standard "Open Source" Corda CorDapps + */ +object MigrationExporter { + + const val LIQUIBASE_HEADER = "--liquibase formatted sql" + const val CORDA_USER = "R3.Corda.Generated" + + fun generateMigrationForCorDapp(schemaName: String, parent: Path = File(".").toPath()): Path { + val schemaClass = Class.forName(schemaName) + val schemaObject = schemaClass.kotlin.objectInstance as MappedSchema + return generateMigrationForCorDapp(schemaObject, parent) + } + + fun generateMigrationForCorDapp(mappedSchema: MappedSchema, parent: Path): Path { + + //create hibernate metadata for MappedSchema + val metadata = createHibernateMetadataForSchema(mappedSchema) + + //create output file and add metadata + val outputFile = File(parent.toFile(), "${migrationResourceNameForSchema(mappedSchema)}.sql") + outputFile.apply { + parentFile.mkdirs() + delete() + createNewFile() + appendText(LIQUIBASE_HEADER) + appendText("\n\n") + appendText("--changeset ${CORDA_USER}:initial_schema_for_${mappedSchema::class.simpleName!!}") + appendText("\n") + } + + //export the schema to that file + SchemaExport().apply { + setDelimiter(";") + setFormat(true) + setOutputFile(outputFile.absolutePath) + execute(EnumSet.of(TargetType.SCRIPT), SchemaExport.Action.CREATE, metadata) + } + return outputFile.toPath() + } + + private fun createHibernateMetadataForSchema(mappedSchema: MappedSchema): Metadata { + val metadataSources = MetadataSources(BootstrapServiceRegistryBuilder().build()) + val config = Configuration(metadataSources) + mappedSchema.mappedTypes.forEach { config.addAnnotatedClass(it) } + val regBuilder = config.standardServiceRegistryBuilder + .applySetting("hibernate.dialect", HibernateGenericDialect::class.java.name) + val metadataBuilder = metadataSources.getMetadataBuilder(regBuilder.build()) + + return HibernateConfiguration.buildHibernateMetadata(metadataBuilder, "", + listOf(DummyAbstractPartyToX500NameAsStringConverter())) + } + + /** + * used just for generating columns + */ + @Converter(autoApply = true) + class DummyAbstractPartyToX500NameAsStringConverter : AttributeConverter { + + override fun convertToDatabaseColumn(party: AbstractParty?) = null + + override fun convertToEntityAttribute(dbData: String?) = null + } + + /** + * Simplified hibernate dialect used for generating liquibase migration files + */ + class HibernateGenericDialect : Dialect() { + init { + registerColumnType(Types.BIGINT, "bigint") + registerColumnType(Types.BOOLEAN, "boolean") + registerColumnType(Types.BLOB, "blob") + registerColumnType(Types.CLOB, "clob") + registerColumnType(Types.DATE, "date") + registerColumnType(Types.FLOAT, "float") + registerColumnType(Types.TIME, "time") + registerColumnType(Types.TIMESTAMP, "timestamp") + registerColumnType(Types.VARCHAR, "varchar(\$l)") + registerColumnType(Types.BINARY, "binary") + registerColumnType(Types.BIT, "boolean") + registerColumnType(Types.CHAR, "char(\$l)") + registerColumnType(Types.DECIMAL, "decimal(\$p,\$s)") + registerColumnType(Types.NUMERIC, "decimal(\$p,\$s)") + registerColumnType(Types.DOUBLE, "double") + registerColumnType(Types.INTEGER, "integer") + registerColumnType(Types.LONGVARBINARY, "longvarbinary") + registerColumnType(Types.LONGVARCHAR, "longvarchar") + registerColumnType(Types.REAL, "real") + registerColumnType(Types.SMALLINT, "smallint") + registerColumnType(Types.TINYINT, "tinyint") + } + } +} \ No newline at end of file diff --git a/node/src/test/kotlin/net/corda/node/ArgsParserTest.kt b/node/src/test/kotlin/net/corda/node/ArgsParserTest.kt index 67d743e72f..b20e69e6c4 100644 --- a/node/src/test/kotlin/net/corda/node/ArgsParserTest.kt +++ b/node/src/test/kotlin/net/corda/node/ArgsParserTest.kt @@ -27,7 +27,8 @@ class ArgsParserTest { justGenerateNodeInfo = false, justRunDbMigration = false, bootstrapRaftCluster = false, - generateDatabaseMigrationToFile = Pair(false, null) + generateDatabaseMigrationToFile = Pair(false, null), + justCreateMigrationForCorDapp = null )) } diff --git a/node/src/test/kotlin/net/corda/node/services/persistence/SchemaMigrationTest.kt b/node/src/test/kotlin/net/corda/node/services/persistence/SchemaMigrationTest.kt index e9881447fb..4737f9b348 100644 --- a/node/src/test/kotlin/net/corda/node/services/persistence/SchemaMigrationTest.kt +++ b/node/src/test/kotlin/net/corda/node/services/persistence/SchemaMigrationTest.kt @@ -2,6 +2,10 @@ package net.corda.node.services.persistence import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource +import net.corda.core.contracts.UniqueIdentifier +import net.corda.core.identity.AbstractParty +import net.corda.core.schemas.CommonSchemaV1 +import net.corda.core.schemas.MappedSchema import net.corda.node.internal.configureDatabase import net.corda.node.services.schema.NodeSchemaService import net.corda.nodeapi.internal.persistence.CordaPersistence @@ -9,10 +13,16 @@ import net.corda.nodeapi.internal.persistence.DatabaseConfig import net.corda.nodeapi.internal.persistence.SchemaMigration import net.corda.testing.internal.rigorousMock import net.corda.testing.node.MockServices +import org.apache.commons.io.FileUtils import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatExceptionOfType import org.junit.Test import java.math.BigInteger +import java.net.URL +import javax.persistence.* +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path + class SchemaMigrationTest { @@ -49,6 +59,31 @@ class SchemaMigrationTest { checkMigrationRun(db) } + @Test + fun `The migration picks up migration files on the classpath if they follow the convention`() { + val dataSourceProps = MockServices.makeTestDataSourceProperties() + + // create a migration file for the DummyTestSchemaV1 and add it to the classpath + val tmpFolder = Files.createTempDirectory("test") + val fileName = MigrationExporter.generateMigrationForCorDapp(DummyTestSchemaV1, tmpFolder).fileName + addToClassPath(tmpFolder) + + // run the migrations for DummyTestSchemaV1, which should pick up the migration file + val db = configureDatabase(dataSourceProps, DatabaseConfig(runMigration = true), rigorousMock(), NodeSchemaService(extraSchemas = setOf(DummyTestSchemaV1))) + + // check that the file was picked up + val nrOfChangesOnDiscoveredFile = db.dataSource.connection.use { + it.createStatement().executeQuery("select count(*) from DATABASECHANGELOG where filename ='migration/${fileName}'").use { rs -> + rs.next() + rs.getInt(1) + } + } + assertThat(nrOfChangesOnDiscoveredFile).isGreaterThan(0) + + //clean up + FileUtils.deleteDirectory(tmpFolder.toFile()) + } + private fun checkMigrationRun(db: CordaPersistence) { //check that the hibernate_sequence was created which means the migration was run db.transaction { @@ -56,4 +91,31 @@ class SchemaMigrationTest { assertThat(value).isGreaterThan(BigInteger.ZERO) } } + + //hacky way to add a folder to the classpath + fun addToClassPath(file: Path) = URLClassLoader::class.java.getDeclaredMethod("addURL", URL::class.java).apply { + isAccessible = true + invoke(ClassLoader.getSystemClassLoader(), file.toFile().toURL()) + } + + object DummyTestSchema + object DummyTestSchemaV1 : MappedSchema(schemaFamily = DummyTestSchema.javaClass, version = 1, mappedTypes = listOf(PersistentDummyTestState::class.java)) { + + @Entity + @Table(name = "dummy_test_states") + class PersistentDummyTestState( + + @ElementCollection + @Column(name = "participants") + @CollectionTable(name = "dummy_deal_states_participants", joinColumns = arrayOf( + JoinColumn(name = "output_index", referencedColumnName = "output_index"), + JoinColumn(name = "transaction_id", referencedColumnName = "transaction_id"))) + override var participants: MutableSet? = null, + + @Transient + val uid: UniqueIdentifier + + ) : CommonSchemaV1.LinearState(uuid = uid.id, externalId = uid.externalId, participants = participants) + } + } \ No newline at end of file From 4b3a77b5cb06d7ea6ca248aaf96b041805ef6782 Mon Sep 17 00:00:00 2001 From: Tudor Malene Date: Fri, 12 Jan 2018 18:38:20 +0000 Subject: [PATCH 2/2] attempt to fix db integration tests (#350) --- .../net/corda/node/services/persistence/MigrationExporter.kt | 0 .../net/corda/node/services/persistence/SchemaMigrationTest.kt | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {node => node-api}/src/main/kotlin/net/corda/node/services/persistence/MigrationExporter.kt (100%) rename {node => node-api}/src/test/kotlin/net/corda/node/services/persistence/SchemaMigrationTest.kt (100%) diff --git a/node/src/main/kotlin/net/corda/node/services/persistence/MigrationExporter.kt b/node-api/src/main/kotlin/net/corda/node/services/persistence/MigrationExporter.kt similarity index 100% rename from node/src/main/kotlin/net/corda/node/services/persistence/MigrationExporter.kt rename to node-api/src/main/kotlin/net/corda/node/services/persistence/MigrationExporter.kt diff --git a/node/src/test/kotlin/net/corda/node/services/persistence/SchemaMigrationTest.kt b/node-api/src/test/kotlin/net/corda/node/services/persistence/SchemaMigrationTest.kt similarity index 100% rename from node/src/test/kotlin/net/corda/node/services/persistence/SchemaMigrationTest.kt rename to node-api/src/test/kotlin/net/corda/node/services/persistence/SchemaMigrationTest.kt