diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 52ebc8cb28..b0e8cabfdd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,6 +66,7 @@ xdk-webcli = { group = "org.xtclang", name = "lib-webcli" } xdk-xenia = { group = "org.xtclang", name = "lib-xenia" } xdk-xml = { group = "org.xtclang", name = "lib-xml" } xdk-xunit = { group = "org.xtclang", name = "lib-xunit" } +xdk-xunit-db = { group = "org.xtclang", name = "lib-xunit-db" } xdk-xunit-engine = { group = "org.xtclang", name = "lib-xunit-engine" } javatools = { group = "org.xtclang", name = "javatools" } diff --git a/lib_xunit_db/build.gradle.kts b/lib_xunit_db/build.gradle.kts new file mode 100644 index 0000000000..6c493cbb82 --- /dev/null +++ b/lib_xunit_db/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.xtc) +} + +dependencies { + xdkJavaTools(libs.javatools) + xtcModule(libs.xdk.ecstasy) + xtcModule(libs.xdk.aggregate) + xtcModule(libs.xdk.collections) + xtcModule(libs.xdk.json) + xtcModule(libs.xdk.jsondb) + xtcModule(libs.xdk.oodb) + xtcModule(libs.xdk.xunit) +} diff --git a/lib_xunit_db/src/main/x/xunit_db.x b/lib_xunit_db/src/main/x/xunit_db.x new file mode 100644 index 0000000000..39bb833335 --- /dev/null +++ b/lib_xunit_db/src/main/x/xunit_db.x @@ -0,0 +1,15 @@ +/** + * The XUnit DB test framework provides functionality to test Ecstasy database applications. + * + * The XUnit DB module is a `TestEngineExtender` so that it is automatically loaded by the XUnit + * test framework when it is imported into a module. + */ +@TestEngineExtender(extensions.createTestEngineExtensions) +module xunit_db.xtclang.org { + + package jsondb import jsondb.xtclang.org; + package oodb import oodb.xtclang.org; + package xunit import xunit.xtclang.org; + + import xunit.annotations.TestEngineExtender; +} \ No newline at end of file diff --git a/lib_xunit_db/src/main/x/xunit_db/DatabaseTest.x b/lib_xunit_db/src/main/x/xunit_db/DatabaseTest.x new file mode 100644 index 0000000000..9efc88beea --- /dev/null +++ b/lib_xunit_db/src/main/x/xunit_db/DatabaseTest.x @@ -0,0 +1,36 @@ +import oodb.RootSchema; + +/** + * An annotation that can be applied to a test fixture to configure a test database. + * + * The test database configurations will be used by any tests in the annotated fixture and its + * subclasses unless overridden by another annotated fixture. + * + * @param scope the scope of the database. + * @param templateDir the path to a directory of files to copy to initialize the test database + * @param configs an optional function that returns a database configurations for a given + * schema type + */ +annotation DatabaseTest(DbConfig.Scope scope = Shared, + Directory? templateDir = Null, + ConfigProvider? configs = Null) + implements DbConfigProvider + into Class | Method | Function { + + /** + * A function that takes a schema type and optionally returns a database configuration for the + * schema. + */ + typedef function conditional DbConfig (Type) as ConfigProvider; + + @Override + conditional DbConfig configFor(Type schema) { + ConfigProvider? configs = this.configs; + if (configs.is(ConfigProvider)) { + if (DbConfig config := configs(schema)) { + return True, config; + } + } + return True, new DbConfig(scope, templateDir); + } +} \ No newline at end of file diff --git a/lib_xunit_db/src/main/x/xunit_db/DbConfig.x b/lib_xunit_db/src/main/x/xunit_db/DbConfig.x new file mode 100644 index 0000000000..02864b2f98 --- /dev/null +++ b/lib_xunit_db/src/main/x/xunit_db/DbConfig.x @@ -0,0 +1,37 @@ +import oodb.RootSchema; + +/** + * The configuration for a test database schema. + * + * @param Schema the schema type for the database. + * @param scope the scope of the database. + * @param templateDir the directory of files to copy to initialize the test database data + */ +const DbConfig(Scope scope = Shared, + Directory? templateDir = Null) { + /** + * A flag indicating whether the database should be shared between tests. + */ + @Lazy Boolean shared.calc() = scope == Shared; + + DbConfig withScope(Scope scope) = new DbConfig(scope, this.templateDir); + + DbConfig withTemplateDir(Directory? templateDir) = new DbConfig(this.scope, templateDir); + + /** + * An enum representing the scope of a test database. + */ + enum Scope { + /** + * A new database is created that will be shared for all tests below the annotated test + * fixture. For example if the annotated test fixture is a class, all tests in that class + * will share the same database. + */ + Shared, + /** + * A new database is created for each test method. For example if the annotated test fixture + * is a class, each test method in the class will have its own database. + */ + PerTest + } +} \ No newline at end of file diff --git a/lib_xunit_db/src/main/x/xunit_db/DbConfigProvider.x b/lib_xunit_db/src/main/x/xunit_db/DbConfigProvider.x new file mode 100644 index 0000000000..cce9007ef7 --- /dev/null +++ b/lib_xunit_db/src/main/x/xunit_db/DbConfigProvider.x @@ -0,0 +1,25 @@ +import oodb.RootSchema; + +/** + * A provider of test database configurations. + */ +interface DbConfigProvider { + /** + * Return the `DbConfig` for the specified schema type. + * + * @return True iff this database is configured for the specified schema type. + * @return the `DbConfig` to use for the specified schema type + */ + conditional DbConfig configFor(Type schema); + + static DbConfigProvider Default = new DefaultDbConfigProvider(); + + static const DefaultDbConfigProvider + implements DbConfigProvider { + + @Override + conditional DbConfig configFor(Type schema) { + return True, new DbConfig(); + } + } +} \ No newline at end of file diff --git a/lib_xunit_db/src/main/x/xunit_db/database/json/JsonDbProvider.x b/lib_xunit_db/src/main/x/xunit_db/database/json/JsonDbProvider.x new file mode 100644 index 0000000000..dd0fcd1262 --- /dev/null +++ b/lib_xunit_db/src/main/x/xunit_db/database/json/JsonDbProvider.x @@ -0,0 +1,134 @@ +import oodb.RootSchema; + +import xunit.UniqueId; + +/** + * Provides a connection to a JSON database, creating the database if necessary. + */ +service JsonDbProvider(Type type, Module dbModule) + implements Closeable { + + /** + * A type representing a connection to a database with a specific schema type. + */ + typedef (oodb.Connection + Schema) as Connection; + + /** + * The database connections being managed by this provider. + */ + private Map connections = new HashMap(); + + /** + * Create a new database connection. + */ + Connection ensureConnection(UniqueId uniqueId, DbConfig config, Directory dir) + = connections.computeIfAbsent(uniqueId, () -> createConnection(config, dir)); + + /** + * Close any database connection associated with the specified UniqueId. + * + * @param uniqueId the UniqueId associated to the connections to be closed + */ + void close(UniqueId uniqueId) { + if (Connection connection := connections.get(uniqueId)) { + connections.remove(uniqueId); + close(connection); + } + } + + @Override + void close(Exception? e = Null) { + for (Connection connection : connections.values) { + close(connection); + } + connections.clear(); + } + + /** + * Create a Connection. + * + * @param config the DbConfig to use to configure the database + * @param parentDir the parent directory to put the database directory into + */ + private Connection createConnection(DbConfig config, Directory parentDir) { + @Inject Directory testOutputRoot; + @Inject Directory testOutput; + + String dbName = dbModule.simpleName; + Directory buildDir = testOutputRoot.dirFor(dbName).ensure(); + Directory dataDir = config.shared ? parentDir.dirFor(dbName) : testOutput.dirFor(dbName); + + if (dataDir.exists) { + dataDir.deleteRecursively(); + } + + Directory? templateDir = config.templateDir; + if (templateDir.is(Directory)) { + copy(templateDir, dataDir); + } + + dataDir.ensure(); + return jsondb.createConnection(dbModule.simpleName, dataDir, buildDir).as(Connection); + } + + /** + * Close a Connections, safely catching and logging any exceptions. + * + * @param connection the Connection to close + */ + private void close(Connection connection) { + try { + connection.close(); + } catch (Exception e2) { + @Inject Console console; + console.print($"Exception during closing of {connection}: {e2.message}"); + } + } + + /** + * Recursively copies the contents of the given directory to the given destination. + * + * @param src the source directory + * @param dest the destination directory + * + * @throws FileAlreadyExists if the destination directory already exists and is not empty + * @throws FileNotFound if the source directory does not exist + */ + private void copy(Directory src, Directory dest) { + import ecstasy.fs.FileAlreadyExists; + import ecstasy.fs.FileNotFound; + + if (src.exists) { + if (dest.exists && dest.size != 0) { + throw new FileAlreadyExists(dest.path); + } + dest.ensure(); + for (File file : src.files()) { + copy(file, dest.fileFor(file.name)); + } + for (Directory dir : src.dirs()) { + copy(dir, dest.dirFor(dir.name)); + } + } else { + throw new FileNotFound(src.path); + } + } + + /** + * Copies the contents of the given file to the given destination. + * + * @param src the source file + * @param dest the destination file + * + * @throws FileAlreadyExists if the destination file already exists + */ + private void copy(File src, File dest) { + import ecstasy.fs.FileAlreadyExists; + if (src.exists) { + if (dest.exists) { + throw new FileAlreadyExists(dest.path); + } + dest.append(src.contents); + } + } +} \ No newline at end of file diff --git a/lib_xunit_db/src/main/x/xunit_db/extensions.x b/lib_xunit_db/src/main/x/xunit_db/extensions.x new file mode 100644 index 0000000000..5868a5763a --- /dev/null +++ b/lib_xunit_db/src/main/x/xunit_db/extensions.x @@ -0,0 +1,13 @@ +import xunit.extensions.Extension; + +/** + * The extensions package contains XUnit extensions for database testing. + */ +package extensions { + /** + * Create the extensions to be registered with the XUnit test. + */ + static Extension[] createTestEngineExtensions() { + return [new DbInjector()]; + } +} \ No newline at end of file diff --git a/lib_xunit_db/src/main/x/xunit_db/extensions/DbInjector.x b/lib_xunit_db/src/main/x/xunit_db/extensions/DbInjector.x new file mode 100644 index 0000000000..601037c782 --- /dev/null +++ b/lib_xunit_db/src/main/x/xunit_db/extensions/DbInjector.x @@ -0,0 +1,141 @@ +import ecstasy.annotations.Inject.Options; + +import oodb.Connection; +import oodb.RootSchema; + +import database.json.JsonDbProvider; + +import xunit.MethodOrFunction; +import xunit.UniqueId; + +import xunit.extensions.AfterAllCallback; +import xunit.extensions.ExecutionContext; +import xunit.extensions.FixtureExecutionCallback; +import xunit.extensions.ResourceLookupCallback; + +/** + * An XUnit test extension that provides database connections for injection into test code. + */ +service DbInjector + implements AfterAllCallback + implements ResourceLookupCallback + implements FixtureExecutionCallback { + + /** + * A map of database providers keyed by the database schema type. + */ + private Map dbByModule = new HashMap(); + + /** + * The database configuration hierarchy. + */ + private ConfigHolder? configHolder = Null; + + @Override + conditional Object lookup(Type type, String name, Options opts = Null) { + if (type.is(Type)) { + @Inject ExecutionContext context; + @Inject Directory testOutput; + + assert Type schemaType := type.resolveFormalType("Schema"); + assert schemaType.is(Type); + + DbConfig config; + UniqueId uniqueId; + Directory dir; + ConfigHolder? configHolder = this.configHolder; + if (configHolder.is(ConfigHolder)) { + config = configHolder.configFor(schemaType); + dir = configHolder.dir; + uniqueId = config.shared ? configHolder.uniqueId : context.uniqueId; + } else { + config = new DbConfig(); + dir = testOutput; + uniqueId = context.uniqueId; + } + + JsonDbProvider? provider + = dbByModule.computeIfAbsent(schemaType, () -> createProvider(schemaType)); + + Connection conn = provider.ensureConnection(uniqueId, config, dir); + return True, type.is(Type) + ? &conn.maskAs(type) + : &conn.maskAs(type); + } + return False; + } + + @Override + void afterAll(ExecutionContext context) { + dbByModule.values.forEach(provider -> provider.close()); + dbByModule.clear(); + } + + @Override + void beforeFixtureExecution(ExecutionContext context) { + @Inject("testOutputRoot") Directory testOutputRoot; + + switch(context.uniqueId.type) { + case Module: + case Package: + case Class: + Class? testClass = context.testClass; + assert testClass.is(Class); + if (testClass.is(DatabaseTest)) { + Directory dir = xunit.extensions.testDirectoryFor(testOutputRoot, testClass); + configHolder = new ConfigHolder(context.uniqueId, testClass, dir, configHolder); + } else if (context.uniqueId.type == Module) { + Directory dir = xunit.extensions.testDirectoryFor(testOutputRoot, testClass); + configHolder = new ConfigHolder(context.uniqueId, DbConfigProvider.Default, dir, configHolder); + } + break; + default: + MethodOrFunction? testMethod = context.testMethod; + if (testMethod.is(DatabaseTest)) { + @Inject("testOutput") Directory dir; + configHolder = new ConfigHolder(context.uniqueId, testMethod, dir, configHolder); + } + break; + } + } + + @Override + void afterFixtureExecution(ExecutionContext context) { + UniqueId uniqueId = context.uniqueId; + dbByModule.values.forEach(provider -> provider.close(uniqueId)); + ConfigHolder? holder = this.configHolder; + if (holder.is(ConfigHolder), holder.uniqueId == uniqueId) { + this.configHolder = holder.parent; + } + } + + + /** + * Creates a new database provider for the given schema type. + */ + private + JsonDbProvider createProvider(Type type) { + assert Class clz := type.fromClass(); + String path = clz.path; + assert Int colon := path.indexOf(':'); + String moduleName = path[0 ..< colon]; + assert Module dbModule := typeSystem.moduleByQualifiedName.get(moduleName); + return new JsonDbProvider(type, dbModule); + } + + /** + * A holder for database configurations. + */ + static const ConfigHolder(UniqueId uniqueId, + DbConfigProvider configs, + Directory dir, + ConfigHolder? parent) { + + DbConfig configFor(Type schema) { + if (DbConfig config := configs.configFor(schema)) { + return config; + } + return parent?.configFor(schema) : new DbConfig(); + } + } +} \ No newline at end of file diff --git a/manualTests/src/main/x/dbTests/MultiDB.x b/manualTests/src/main/x/dbTests/MultiDB.x index b15c350fda..ce59a67a8c 100644 --- a/manualTests/src/main/x/dbTests/MultiDB.x +++ b/manualTests/src/main/x/dbTests/MultiDB.x @@ -4,6 +4,8 @@ module MultiDB { import oodb.*; + typedef (oodb.Connection + MainSchema) as Connection; + interface MainSchema extends RootSchema { @RO Counter counter; diff --git a/manualTests/src/main/x/dbTests/MultiTest.x b/manualTests/src/main/x/dbTests/MultiTest.x index 3a97f950be..56344e72f5 100644 --- a/manualTests/src/main/x/dbTests/MultiTest.x +++ b/manualTests/src/main/x/dbTests/MultiTest.x @@ -1,30 +1,35 @@ /** - * A stand-alone test for multi level schema. + * A stand-alone test that uses XUnit and XUnit-DB to test a multi level schema. * * To run, from "./manualTests/" directory: - * xcc -L build/xtc/main/lib -o build/xtc/main/lib src/main/x/dbTests/MultiDB.x - * xec -L build/xtc/main/lib -o build/xtc/main/lib src/main/x/dbTests/MultiTest.x + * xtc build -L build/xtc/main/lib -o build/xtc/main/lib src/main/x/dbTests/MultiDB.x + * xtc test -L build/xtc/main/lib -o build/xtc/main/lib src/main/x/dbTests/MultiTest.x */ module MultiTest { - package jsondb import jsondb.xtclang.org; + package oodb import oodb.xtclang.org; package multiDB import MultiDB; + package xunit import xunit.xtclang.org; + package xunitdb import xunit_db.xtclang.org; - import multiDB.MainSchema; + import multiDB.Connection; - void run() { - @Inject Console console; - @Inject Directory curDir; - assert curDir.fileFor("src/main/x/dbTests/MultiDB.x").exists - as "Not in \"manualTests\" directory"; + @Inject Connection connection; - Directory buildDir = curDir.dirFor("build/xtc/main/lib"); - assert buildDir.fileFor("MultiDB.xtc").exists - as "MultiDB must be compiled to the build/xtc/main/lib directory"; + @Test + void shouldTickInMainSchema() { + Int current = connection.counter.get(); + Int previous = connection.counter.tick(); + assert previous == current; + Int after = connection.counter.get(); + assert after == current + 1; + } - Directory dataDir = curDir.dirFor("data/multiDB").ensure(); - using (MainSchema schema = jsondb.createConnection("MultiDB", dataDir, buildDir).as(MainSchema)) { - console.print($"{schema.counter.tick()=}"); - console.print($"{schema.child.counter.tick()=}"); - } + @Test + void shouldTickInChildSchema() { + Int current = connection.child.counter.get(); + Int previous = connection.child.counter.tick(); + assert previous == current; + Int after = connection.child.counter.get(); + assert after == current + 2; } } diff --git a/xdk/build.gradle.kts b/xdk/build.gradle.kts index a972e6b112..e642d0f5f4 100644 --- a/xdk/build.gradle.kts +++ b/xdk/build.gradle.kts @@ -88,6 +88,7 @@ dependencies { xtcModule(libs.xdk.xenia) xtcModule(libs.xdk.xml) xtcModule(libs.xdk.xunit) + xtcModule(libs.xdk.xunit.db) xtcModule(libs.xdk.xunit.engine) xtcModule(libs.javatools.bridge) xtcLauncherBinaries(project(path = ":javatools-launcher", configuration = "xtcLauncherBinaries")) diff --git a/xdk/settings.gradle.kts b/xdk/settings.gradle.kts index a75431d11f..bda6186277 100644 --- a/xdk/settings.gradle.kts +++ b/xdk/settings.gradle.kts @@ -34,6 +34,7 @@ listOf( "lib_xenia", "lib_xml", "lib_xunit", + "lib_xunit_db", "lib_xunit_engine", "javatools_turtle", "javatools_launcher",