From b810b14d75dddd5db463c91cafde7fc36757d965 Mon Sep 17 00:00:00 2001 From: Vijay Nayar Date: Sun, 26 Nov 2023 12:29:43 +0100 Subject: [PATCH] #79 Embedded Annotation with Optional Column Prefix (#81) * Added @Embedded annotation and use prefix for table creation. Next: Fix errors on SELECT. * Added logic for SELECT columns to respect embedded prefixes. * Added @Embedded prefix support for insert/update. * Add documentation of functions explored. * Organizing tests, adding more tests for embedded. * Complete test coverage for embedded logic. * Fix for MYSQL compilation flag in integration tests. * One more fix for MySQL tests. * Sigh... one more typo on Postgres config. * Remove TODO statements that were left behind. --- .gitignore | 4 + hdtest/dub.json | 1 + hdtest/source/embeddedtest.d | 116 ++++++++++++ hdtest/source/generaltest.d | 248 +++++++++++++++++++++++++ hdtest/source/hibernatetest.d | 100 +++++++++++ hdtest/source/htestmain.d | 310 ++------------------------------ hdtest/source/testrunner.d | 92 ++++++++++ source/hibernated/annotations.d | 34 ++++ source/hibernated/metadata.d | 67 +++++-- source/hibernated/query.d | 31 +++- source/hibernated/session.d | 14 +- 11 files changed, 694 insertions(+), 323 deletions(-) create mode 100644 hdtest/source/embeddedtest.d create mode 100644 hdtest/source/generaltest.d create mode 100644 hdtest/source/hibernatetest.d create mode 100644 hdtest/source/testrunner.d diff --git a/.gitignore b/.gitignore index 2a4be51..abbb52d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,7 @@ examples/example1/example1 # Ignore Sublime Text workspace (project file is ok): *.sublime-workspace + +# Emacs/Vim +*~ +\#*\# \ No newline at end of file diff --git a/hdtest/dub.json b/hdtest/dub.json index 1a59ad0..1337019 100644 --- a/hdtest/dub.json +++ b/hdtest/dub.json @@ -8,6 +8,7 @@ "hibernated": {"version": "~master", "path": "../"} }, "buildRequirements": ["allowWarnings"], + "mainSourceFile": "source/htestmain.d", "configurations": [ { "name": "full", diff --git a/hdtest/source/embeddedtest.d b/hdtest/source/embeddedtest.d new file mode 100644 index 0000000..99c017b --- /dev/null +++ b/hdtest/source/embeddedtest.d @@ -0,0 +1,116 @@ +module embeddedtest; + +import hibernated.core; + +import testrunner : BeforeClass, Test, AfterClass, runTests; +import hibernatetest : HibernateTest; + +@Embeddable +class Address { + string street; + string city; +} + +class Customer { + @Id @Generated + long cid; + + string name; + + Address shippingAddress; + + @Embedded("billing") + Address billingAddress; +} + +class EmbeddedTest : HibernateTest { + override + EntityMetaData buildSchema() { + return new SchemaInfoImpl!(Customer, Address); + } + + @Test("embedded.creation") + void creationTest() { + Session sess = sessionFactory.openSession(); + scope(exit) sess.close(); + + Customer c1 = new Customer(); + c1.name = "Kickflip McOllie"; + c1.shippingAddress = new Address(); + c1.shippingAddress.street = "1337 Rad Street"; + c1.shippingAddress.city = "Awesomeville"; + c1.billingAddress = new Address(); + c1.billingAddress.street = "101001 Robotface"; + c1.billingAddress.city = "Lametown"; + + long c1Id = sess.save(c1).get!long; + assert(c1Id > 0); + } + + @Test("embedded.read") + void readTest() { + Session sess = sessionFactory.openSession(); + scope(exit) sess.close(); + + auto r1 = sess.createQuery("FROM Customer WHERE shippingAddress.city = :City") + .setParameter("City", "Awesomeville"); + Customer c1 = r1.uniqueResult!Customer(); + assert(c1 !is null); + assert(c1.shippingAddress.street == "1337 Rad Street"); + + auto r2 = sess.createQuery("FROM Customer WHERE billingAddress.city = :City") + .setParameter("City", "Lametown"); + Customer c2 = r2.uniqueResult!Customer(); + assert(c2 !is null); + assert(c2.billingAddress.street == "101001 Robotface"); + } + + @Test("embedded.update") + void updateTest() { + Session sess = sessionFactory.openSession(); + + auto r1 = sess.createQuery("FROM Customer WHERE billingAddress.city = :City") + .setParameter("City", "Lametown"); + Customer c1 = r1.uniqueResult!Customer(); + assert(c1 !is null); + + c1.billingAddress.street = "17 Neat Street"; + sess.update(c1); + + // Create a new session to prevent caching. + sess.close(); + sess = sessionFactory.openSession(); + + r1 = sess.createQuery("FROM Customer WHERE billingAddress.city = :City") + .setParameter("City", "Lametown"); + c1 = r1.uniqueResult!Customer(); + assert(c1 !is null); + assert(c1.billingAddress.street == "17 Neat Street"); + + sess.close(); + } + + @Test("embedded.delete") + void deleteTest() { + Session sess = sessionFactory.openSession(); + + auto r1 = sess.createQuery("FROM Customer WHERE billingAddress.city = :City") + .setParameter("City", "Lametown"); + Customer c1 = r1.uniqueResult!Customer(); + assert(c1 !is null); + + sess.remove(c1); + + // Create a new session to prevent caching. + sess.close(); + sess = sessionFactory.openSession(); + + r1 = sess.createQuery("FROM Customer WHERE billingAddress.city = :City") + .setParameter("City", "Lametown"); + c1 = r1.uniqueResult!Customer(); + assert(c1 is null); + + sess.close(); + } + +} diff --git a/hdtest/source/generaltest.d b/hdtest/source/generaltest.d new file mode 100644 index 0000000..2635ce0 --- /dev/null +++ b/hdtest/source/generaltest.d @@ -0,0 +1,248 @@ +module generaltest; + +import std.typecons; +import std.stdio; +import std.format; +import std.conv; + +import hibernated.core; + +import testrunner : BeforeClass, Test, AfterClass, runTests; +import hibernatetest : HibernateTest; + +// Annotations of entity classes +@Table( "gebruiker" ) +class User { + long id; + string name; + int some_field_with_underscore; + @ManyToMany // cannot be inferred, requires annotation + LazyCollection!Role roles; + //@ManyToOne + MyGroup group; + + @OneToMany + Address[] addresses; + + Asset[] assets; + + override string toString() { + return format("{id: %s, name: %s, roles: %s, group: %s}", + id, name, roles, group); + } +} + +class Role { + int id; + string name; + @ManyToMany // w/o this annotation will be OneToMany by convention + LazyCollection!User users; + + override string toString() { + return format("{id: %s, name: %s}", id, name); + } +} + +class Address { + @Generated @Id int addressId; + User user; + string street; + string town; + string country; + + override string toString() { + return format("{id: %s, user: %s, street: %s, town: %s, country: %s}", addressId, user, street, town, country); + } +} + +class Asset { + @Generated @Id int id; + User user; + string name; + + override string toString() { + return format("{id: %s, name: %s}", id, name); + } +} + +@Entity +class MyGroup { + long id; + string name; + @OneToMany + LazyCollection!User users; + + override string toString() { + return format("{id: %s, name: %s}", id, name); + } +} + +class GeneralTest : HibernateTest { + override + EntityMetaData buildSchema() { + return new SchemaInfoImpl!(User, Role, Address, Asset, MyGroup); + } + + @Test("general test") + void generalTest() { + // create session + Session sess = sessionFactory.openSession(); + scope(exit) sess.close(); + + // use session to access DB + + writeln("Querying empty DB"); + Query q = sess.createQuery("FROM User ORDER BY name"); + User[] list = q.list!User(); + writeln("Result size is " ~ to!string(list.length)); + assert(list.length == 0); + + // create sample data + writeln("Creating sample schema"); + MyGroup grp1 = new MyGroup(); + grp1.name = "Group-1"; + MyGroup grp2 = new MyGroup(); + grp2.name = "Group-2"; + MyGroup grp3 = new MyGroup(); + grp3.name = "Group-3"; + + Role r10 = new Role(); + r10.name = "role10"; + Role r11 = new Role(); + r11.name = "role11"; + + // create a user called Alex with an address and an asset + User u10 = new User(); + u10.name = "Alex"; + u10.roles = [r10, r11]; + u10.group = grp3; + + auto address = new Address(); + address.street = "Some Street"; + address.town = "Big Town"; + address.country = "Alaska"; + address.user = u10; + writefln("Saving Address: %s", address); + sess.save(address); + + u10.addresses = [address]; + + auto asset = new Asset(); + asset.name = "Something Precious"; + asset.user = u10; + writefln("Saving Asset: %s", asset); + sess.save(asset); + u10.assets = [asset]; + + User u12 = new User(); + u12.name = "Arjan"; + u12.roles = [r10, r11]; + u12.group = grp2; + + User u13 = new User(); + u13.name = "Wessel"; + u13.roles = [r10, r11]; + u13.group = grp2; + + writeln("saving group 1-2-3" ); + sess.save( grp1 ); + sess.save( grp2 ); + sess.save( grp3 ); + + writeln("Saving Role r10: " ~ r10.name); + sess.save(r10); + + writeln("Saving Role r11: " ~ r11.name); + sess.save(r11); + + { + writeln("Saving User u10: " ~ u10.name ~ "..."); + long id = sess.save(u10).get!long; + assert(id > 0L); + writeln("\tuser saved with id: " ~ to!string(id)); + } + + { + writeln("Saving User u12: " ~ u12.name ~ "..."); + long id = sess.save(u12).get!long; + assert(id > 0L); + writeln("\tuser saved with id: " ~ to!string(id)); + } + + { + writeln("Saving User u13: " ~ u13.name ~ "..."); + long id = sess.save(u13).get!long; + assert(id > 0L); + writeln("\tuser saved with id: " ~ to!string(id)); + } + + writeln("Querying User by name 'Alex'..."); + // load and check data + auto qresult = sess.createQuery("FROM User WHERE name=:Name and some_field_with_underscore != 42").setParameter("Name", "Alex"); + writefln( "\tquery result: %s", qresult.listRows() ); + User u11 = qresult.uniqueResult!User(); + writefln("\tChecking fields for User 11 : %s", u11); + assert(u11.roles.length == 2); + assert(u11.roles[0].name == "role10" || u11.roles.get()[0].name == "role11"); + assert(u11.roles[1].name == "role10" || u11.roles.get()[1].name == "role11"); + assert(u11.roles[0].users.length == 3); + assert(u11.roles[0].users[0] == u10); + + assert(u11.addresses.length == 1); + assert(u11.addresses[0].street == "Some Street"); + assert(u11.addresses[0].town == "Big Town"); + assert(u11.addresses[0].country == "Alaska"); + + assert(u11.assets.length == 1); + assert(u11.assets[0].name == "Something Precious"); + + // selecting all from address table should return a row that joins to the user table + auto allAddresses = sess.createQuery("FROM Address").list!Address(); + assert(allAddresses.length == 1); + writefln("Found address : %s", allAddresses[0]); + assert(allAddresses[0].street == "Some Street"); + assert(allAddresses[0].user == u11); + + // selecting all from asset table should return a row that joins to the user table + auto allAssets = sess.createQuery("FROM Asset").list!Asset(); + assert(allAssets.length == 1); + writefln("Found asset : %s", allAssets[0]); + assert(allAssets[0].name == "Something Precious"); + assert(allAssets[0].user == u11); + + // now test something else + writeln("Test retrieving users by group... (ManyToOne relationship)"); + auto qUsersByGroup = sess.createQuery("FROM User WHERE group=:group_id").setParameter("group_id", grp2.id); + User[] usersByGroup = qUsersByGroup.list!User(); + assert(usersByGroup.length == 2); // user 2 and user 2 + + { + writeln("Updating User u10 name (from Alex to Alexander)..."); + u10.name = "Alexander"; + sess.update(u10); + + User u = sess.createQuery("FROM User WHERE id=:uid") + .setParameter("uid", u10.id) + .uniqueResult!User(); + assert(u.id == u10.id); + assert(u.name == "Alexander"); + } + + // remove reference + //std.algorithm.remove(u11.roles.get(), 0); + //sess.update(u11); + + { + auto allUsers = sess.createQuery("FROM User").list!User(); + assert(allUsers.length == 3); // Should be 3 user nows + } + writeln("Removing User u11"); + sess.remove(u11); + + { + auto allUsers = sess.createQuery("FROM User").list!User(); + assert(allUsers.length == 2); // Should only be 2 users now + } + } +} + diff --git a/hdtest/source/hibernatetest.d b/hdtest/source/hibernatetest.d new file mode 100644 index 0000000..1c3b3ab --- /dev/null +++ b/hdtest/source/hibernatetest.d @@ -0,0 +1,100 @@ +module hibernatetest; + +import std.stdio; + +import hibernated.core; + +import testrunner : Test, BeforeClass, AfterClass; + +/** + * Generic parameters to connect to a database, independent of the driver. + */ +struct ConnectionParams { + string host; + ushort port; + string database; + string user; + string pass; +} + +/** + * A base-class for most hibernate tests. It takes care of setting up and tearing down a connection + * to a database using the appropriate driver and connection parameters. Tests annotated with + * `@Test` can simply use a session factory to test hibernate queries. + */ +abstract class HibernateTest { + ConnectionParams connectionParams; + SessionFactory sessionFactory; + + EntityMetaData buildSchema(); + + Dialect buildDialect() { + version (USE_SQLITE) { + Dialect dialect = new SQLiteDialect(); + } else version (USE_MYSQL) { + Dialect dialect = new MySQLDialect(); + } else version (USE_PGSQL) { + Dialect dialect = new PGSQLDialect(); + } + return dialect; + } + DataSource buildDataSource(ConnectionParams connectionParams) { + // setup DB connection + version( USE_SQLITE ) + { + import ddbc.drivers.sqliteddbc; + string[string] params; + DataSource ds = new ConnectionPoolDataSourceImpl(new SQLITEDriver(), "zzz.db", params); + } + else version( USE_MYSQL ) + { + import ddbc.drivers.mysqlddbc; + immutable string url = MySQLDriver.generateUrl( + connectionParams.host, connectionParams.port, connectionParams.database); + string[string] params = MySQLDriver.setUserAndPassword( + connectionParams.user, connectionParams.pass); + DataSource ds = new ConnectionPoolDataSourceImpl(new MySQLDriver(), url, params); + } + else version( USE_PGSQL ) + { + import ddbc.drivers.pgsqlddbc; + immutable string url = PGSQLDriver.generateUrl( + connectionParams.host, connectionParams.port, connectionParams.database); // PGSQLDriver.generateUrl( "/tmp", 5432, "testdb" ); + string[string] params; + params["user"] = connectionParams.user; + params["password"] = connectionParams.pass; + params["ssl"] = "true"; + + DataSource ds = new ConnectionPoolDataSourceImpl(new PGSQLDriver(), url, params); + } + return ds; + } + + SessionFactory buildSessionFactory() { + DataSource ds = buildDataSource(connectionParams); + SessionFactory factory = new SessionFactoryImpl(buildSchema(), buildDialect(), ds); + + writeln("Creating DB Schema"); + DBInfo db = factory.getDBMetaData(); + { + Connection conn = ds.getConnection(); + scope(exit) conn.close(); + db.updateDBSchema(conn, true, true); + } + return factory; + } + + void setConnectionParams(ConnectionParams connectionParams) { + this.connectionParams = connectionParams; + } + + @BeforeClass + void setup() { + this.sessionFactory = buildSessionFactory(); + } + + @AfterClass + void teardown() { + this.sessionFactory.close(); + } +} diff --git a/hdtest/source/htestmain.d b/hdtest/source/htestmain.d index 818b604..67b0a72 100644 --- a/hdtest/source/htestmain.d +++ b/hdtest/source/htestmain.d @@ -1,311 +1,37 @@ module htestmain; +import std.typecons; import std.algorithm; import std.stdio; import std.string; import std.conv; import std.getopt; import hibernated.core; -import std.traits; -// Annotations of entity classes -@Table( "gebruiker" ) -class User { - long id; - string name; - int some_field_with_underscore; - @ManyToMany // cannot be inferred, requires annotation - LazyCollection!Role roles; - //@ManyToOne - MyGroup group; - - @OneToMany - Address[] addresses; - - Asset[] assets; - - override string toString() { - return format("{id: %s, name: %s, roles: %s, group: %s}", id, name, roles, group); - } -} - -class Role { - int id; - string name; - @ManyToMany // w/o this annotation will be OneToMany by convention - LazyCollection!User users; - - override string toString() { - return format("{id: %s, name: %s}", id, name); - } -} - -class Address { - @Generated @Id int addressId; - User user; - string street; - string town; - string country; - - override string toString() { - return format("{id: %s, user: %s, street: %s, town: %s, country: %s}", addressId, user, street, town, country); - } -} - -class Asset { - @Generated @Id int id; - User user; - string name; - - override string toString() { - return format("{id: %s, name: %s}", id, name); - } -} - -@Entity -class MyGroup { - long id; - string name; - @OneToMany - LazyCollection!User users; - - override string toString() { - return format("{id: %s, name: %s}", id, name); - } -} - -void testHibernate(immutable string host, immutable ushort port, immutable string dbName, immutable string dbUser, immutable string dbPass) { - - // setup DB connection - version( USE_SQLITE ) - { - import ddbc.drivers.sqliteddbc; - string[string] params; - DataSource ds = new ConnectionPoolDataSourceImpl(new SQLITEDriver(), "zzz.db", params); - Dialect dialect = new SQLiteDialect(); - } - else version( USE_MYSQL ) - { - import ddbc.drivers.mysqlddbc; - immutable string url = MySQLDriver.generateUrl(host, port, dbName); - string[string] params = MySQLDriver.setUserAndPassword(dbUser, dbPass); - DataSource ds = new ConnectionPoolDataSourceImpl(new MySQLDriver(), url, params); - Dialect dialect = new MySQLDialect(); - } - else version( USE_PGSQL ) - { - import ddbc.drivers.pgsqlddbc; - immutable string url = PGSQLDriver.generateUrl(host, port, dbName); // PGSQLDriver.generateUrl( "/tmp", 5432, "testdb" ); - string[string] params; - params["user"] = dbUser; - params["password"] = dbPass; - params["ssl"] = "true"; - - DataSource ds = new ConnectionPoolDataSourceImpl(new PGSQLDriver(), url, params); - Dialect dialect = new PGSQLDialect(); - } - - // create metadata from annotations - writeln("Creating schema from class list"); - EntityMetaData schema = new SchemaInfoImpl!(User, Role, Address, Asset, MyGroup); - //writeln("Creating schema from module list"); - //EntityMetaData schema2 = new SchemaInfoImpl!(htestmain); - - - writeln("Creating session factory"); - // create session factory - SessionFactory factory = new SessionFactoryImpl(schema, dialect, ds); - scope(exit) factory.close(); - - writeln("Creating DB schema"); - DBInfo db = factory.getDBMetaData(); - { - Connection conn = ds.getConnection(); - scope(exit) conn.close(); - db.updateDBSchema(conn, true, true); - } - - - // create session - Session sess = factory.openSession(); - scope(exit) sess.close(); - - // use session to access DB - - writeln("Querying empty DB"); - Query q = sess.createQuery("FROM User ORDER BY name"); - User[] list = q.list!User(); - writeln("Result size is " ~ to!string(list.length)); - assert(list.length == 0); - - // create sample data - writeln("Creating sample schema"); - MyGroup grp1 = new MyGroup(); - grp1.name = "Group-1"; - MyGroup grp2 = new MyGroup(); - grp2.name = "Group-2"; - MyGroup grp3 = new MyGroup(); - grp3.name = "Group-3"; - - Role r10 = new Role(); - r10.name = "role10"; - Role r11 = new Role(); - r11.name = "role11"; - - // create a user called Alex with an address and an asset - User u10 = new User(); - u10.name = "Alex"; - u10.roles = [r10, r11]; - u10.group = grp3; - - auto address = new Address(); - address.street = "Some Street"; - address.town = "Big Town"; - address.country = "Alaska"; - address.user = u10; - writefln("Saving Address: %s", address); - sess.save(address); - - u10.addresses = [address]; - - auto asset = new Asset(); - asset.name = "Something Precious"; - asset.user = u10; - writefln("Saving Asset: %s", asset); - sess.save(asset); - u10.assets = [asset]; - - User u12 = new User(); - u12.name = "Arjan"; - u12.roles = [r10, r11]; - u12.group = grp2; - - User u13 = new User(); - u13.name = "Wessel"; - u13.roles = [r10, r11]; - u13.group = grp2; - - writeln("saving group 1-2-3" ); - sess.save( grp1 ); - sess.save( grp2 ); - sess.save( grp3 ); - - writeln("Saving Role r10: " ~ r10.name); - sess.save(r10); - - writeln("Saving Role r11: " ~ r11.name); - sess.save(r11); - - { - writeln("Saving User u10: " ~ u10.name ~ "..."); - long id = sess.save(u10).get!long; - assert(id > 0L); - writeln("\tuser saved with id: " ~ to!string(id)); - } - - { - writeln("Saving User u12: " ~ u12.name ~ "..."); - long id = sess.save(u12).get!long; - assert(id > 0L); - writeln("\tuser saved with id: " ~ to!string(id)); - } - - { - writeln("Saving User u13: " ~ u13.name ~ "..."); - long id = sess.save(u13).get!long; - assert(id > 0L); - writeln("\tuser saved with id: " ~ to!string(id)); - } - - writeln("Querying User by name 'Alex'..."); - // load and check data - auto qresult = sess.createQuery("FROM User WHERE name=:Name and some_field_with_underscore != 42").setParameter("Name", "Alex"); - writefln( "\tquery result: %s", qresult.listRows() ); - User u11 = qresult.uniqueResult!User(); - writefln("\tChecking fields for User 11 : %s", u11); - assert(u11.roles.length == 2); - assert(u11.roles[0].name == "role10" || u11.roles.get()[0].name == "role11"); - assert(u11.roles[1].name == "role10" || u11.roles.get()[1].name == "role11"); - assert(u11.roles[0].users.length == 3); - assert(u11.roles[0].users[0] == u10); - - assert(u11.addresses.length == 1); - assert(u11.addresses[0].street == "Some Street"); - assert(u11.addresses[0].town == "Big Town"); - assert(u11.addresses[0].country == "Alaska"); - - assert(u11.assets.length == 1); - assert(u11.assets[0].name == "Something Precious"); - - // selecting all from address table should return a row that joins to the user table - auto allAddresses = sess.createQuery("FROM Address").list!Address(); - assert(allAddresses.length == 1); - writefln("Found address : %s", allAddresses[0]); - assert(allAddresses[0].street == "Some Street"); - assert(allAddresses[0].user == u11); - - // selecting all from asset table should return a row that joins to the user table - auto allAssets = sess.createQuery("FROM Asset").list!Asset(); - assert(allAssets.length == 1); - writefln("Found asset : %s", allAssets[0]); - assert(allAssets[0].name == "Something Precious"); - assert(allAssets[0].user == u11); - - // now test something else - writeln("Test retrieving users by group... (ManyToOne relationship)"); - auto qUsersByGroup = sess.createQuery("FROM User WHERE group=:group_id").setParameter("group_id", grp2.id); - User[] usersByGroup = qUsersByGroup.list!User(); - assert(usersByGroup.length == 2); // user 2 and user 2 - - { - writeln("Updating User u10 name (from Alex to Alexander)..."); - u10.name = "Alexander"; - sess.update(u10); - - User u = sess.createQuery("FROM User WHERE id=:uid") - .setParameter("uid", u10.id) - .uniqueResult!User(); - assert(u.id == u10.id); - assert(u.name == "Alexander"); - } - - // remove reference - //std.algorithm.remove(u11.roles.get(), 0); - //sess.update(u11); - - { - auto allUsers = sess.createQuery("FROM User").list!User(); - assert(allUsers.length == 3); // Should be 3 user nows - } - writeln("Removing User u11"); - sess.remove(u11); - - { - auto allUsers = sess.createQuery("FROM User").list!User(); - assert(allUsers.length == 2); // Should only be 2 users now - } -} - -struct ConnectionParams { - string host; - ushort port; - string database; - string user; - string pass; -} +import testrunner : runTests; +import hibernatetest : ConnectionParams; +import generaltest : GeneralTest; +import embeddedtest : EmbeddedTest; int main(string[] args) { - ConnectionParams par; - string URI; - try { + ConnectionParams par; + + try { getopt(args, "host",&par.host, "port",&par.port, "database",&par.database, "user",&par.user, "password",&par.pass); } catch (GetOptException) { stderr.writefln("Could not parse args"); return 1; } - testHibernate(par.host, par.port, par.database, par.user, par.pass); - writeln("All scenarios worked successfully"); - return 0; + GeneralTest test1 = new GeneralTest(); + test1.setConnectionParams(par); + runTests(test1); + + EmbeddedTest test2 = new EmbeddedTest(); + test2.setConnectionParams(par); + runTests(test2); + + writeln("All scenarios worked successfully"); + return 0; } diff --git a/hdtest/source/testrunner.d b/hdtest/source/testrunner.d new file mode 100644 index 0000000..e7da7fa --- /dev/null +++ b/hdtest/source/testrunner.d @@ -0,0 +1,92 @@ +module testrunner; + +import std.meta : AliasSeq; +import std.traits; +import std.stdio; + +/** + * When `runTests` is called on an object, methods annotated with `@Test` will be executed + * after `@BeforeClass` methods, but before `@AfterClass` methods. The name of a test will + * be displayed during execution. + */ +struct Test { + string name; +} + +/** + * When `runTests` is called on an object, methods annotaetd with `@BeforeClass` will be executed + * before any other methods. These methods typically set up test resources. + */ +struct BeforeClass { } + +/** + * When `runTests` is called on an object, methods annotaetd with `@BeforeClass` will be executed + * before any other methods. These methdos typically tear down test resources. + */ +struct AfterClass { } + +/** + * Scans a class and executes methods in the following order. + * 1. Class `@BeforeClass` methods. + * 2. Base class `@BeforeClass` methods. + * 3. Class `@Test` methods. + * 4. Base class `@Test` methods. + * 5. Class `@AfterClass` methods. + * 6. Base class `@AfterClass` methods. + */ +void runTests(TestClass)(TestClass testClass) { + // Search for member functions annotated with @BeforeClass and run them. + static foreach (alias method; getSymbolsByUDA!(TestClass, BeforeClass)) { + __traits(getMember, testClass, __traits(identifier, method))(); + } + + // Search for member functions annotated with @Test and run them. + static foreach (alias method; getSymbolsByUDA!(TestClass, Test)) { + writeln("▶ Running Test: ", getUDAs!(method, Test)[0].name); + __traits(getMember, testClass, __traits(identifier, method))(); + } + + // Search for member functions annotated with @AfterClass and run them. + static foreach (alias method; getSymbolsByUDA!(TestClass, AfterClass)) { + __traits(getMember, testClass, __traits(identifier, method))(); + } +} + +unittest { + int[] testOrder; + + class DummyTest { + @BeforeClass + void fanny() { + testOrder ~= 2; + } + + @Test("fanny test") + void mytest() { + testOrder ~= 4; + } + + @AfterClass + void foony() { + testOrder ~= 6; + } + } + + class DoomyTest : DummyTest { + @BeforeClass + void flam() { + testOrder ~= 1; + } + @Test("flam test") + void moytest() { + testOrder ~= 3; + } + @AfterClass + void flom() { + testOrder ~= 5; + } + } + + runTests(new DoomyTest()); + assert(testOrder == [1, 2, 3, 4, 5, 6]); +} diff --git a/source/hibernated/annotations.d b/source/hibernated/annotations.d index fe02a6e..7775f92 100644 --- a/source/hibernated/annotations.d +++ b/source/hibernated/annotations.d @@ -52,6 +52,40 @@ struct Embeddable { // this(bool enabled) {} } +/** + * While the `@Embeddable` annotation is applied to a type which can be included in a class in order + * to add its properties as its own, the `@Embedded` annotation is used for the field itself. + * + * If there is only one `@Embeddable` member in a class, then this annotation is implied and + * optional. However, `@Embedded` can include a prefix, which is needed to distinguish multiple + * embedded properties that have the same type. + * + * Example: + * ``` + * @Embeddable + * class Address { + * string zip; + * string city; + * string streetAddress; + * } + * + * @Table("customers") + * class Customer { + * @Id @Generated + * long id; + * + * @Embedded("shipping") + * Address shipping; // Adds columns like: shipping_zip, shipping_city + * + * @Embedded("billing") + * Address billing; // Adds columns like: billing_zip, billing_city + * } + * ``` + */ +struct Embedded { + string columnPrefix; +} + /** * Use to specify table name for entity. * @Table("table_name") - specifies table name to store entity in, different from default generated. diff --git a/source/hibernated/metadata.d b/source/hibernated/metadata.d index 53a8dbd..80ed164 100755 --- a/source/hibernated/metadata.d +++ b/source/hibernated/metadata.d @@ -87,7 +87,7 @@ abstract class EntityMetaData { public int getFieldCount(const EntityInfo ei, bool exceptKey) const; - public string getAllFieldList(Dialect dialect, const EntityInfo ei, bool exceptKey = false) const; + public string getAllFieldList(Dialect dialect, const EntityInfo ei, bool exceptKey = false, string columnPrefix = "") const; public string getAllFieldList(Dialect dialect, string entityName, bool exceptKey = false) const; public string generateFindByPkForEntity(Dialect dialect, const EntityInfo ei) const; @@ -1019,6 +1019,10 @@ PropertyMemberKind getPropertyMemberKind(T : Object, string m)() { return memberKind; } +/** + * Given a class `T` and the name `m` of a property inside that class, return the name of the type + * of `m` if it is `@Embeddable`, otherwise a static assertion will fail. + */ string getPropertyEmbeddedEntityName(T : Object, string m)() { alias ti = typeof(__traits(getMember, T, m)); @@ -1339,6 +1343,10 @@ template getLazyCollectionInstanceType(T) { } } +/** + * Given the type of a class attribute, whether it is a function, delegate, collection, + * etc. determine its basic property type. E.g. `@property int[] myData() {...}` becomes `int`. + */ template getReferencedInstanceType(T) { //pragma(msg, T.stringof); static if (is(T == delegate)) { @@ -1386,6 +1394,11 @@ template getReferencedInstanceType(T) { } } +/** + * Returns as a string the name of the type of property of an entity. + * + * E.g. given `class A { Thing b; }`, `getPropertyReferencedEntityName!(A, "b")` would be `"Thing"`. + */ string getPropertyReferencedEntityName(T : Object, string m)() { alias ti = typeof(__traits(getMember, T, m)); return getEntityName!(getReferencedInstanceType!ti); @@ -2747,6 +2760,22 @@ string getManyToManyPropertyDef(T, immutable string m)() { ")"; } +/** + * For a given class type `T` and property name `m`, determine the column prefix to add for an + * embedded property. This is is either the value from `Embedded.columnPrefix` or "". + */ +string getEmbeddedPropertyColumnPrefix(T : Object, string m)() { + alias embeddedUDAs = getUDAs!(__traits(getMember, T, m), Embedded); + static if (embeddedUDAs.length == 0) { + return ""; + } else static if (embeddedUDAs.length == 1) { + return embeddedUDAs[0].columnPrefix; + } else { + assert(false, "Only one `@Embedded` annotation is permitted per property."); + } +} + + /// generate source code for creation of Embedded definition string getEmbeddedPropertyDef(T, immutable string m)() { immutable string referencedEntityName = getPropertyEmbeddedEntityName!(T,m); @@ -2755,7 +2784,8 @@ string getEmbeddedPropertyDef(T, immutable string m)() { immutable string propertyName = getPropertyName!(T,m); static assert (propertyName != null, "Cannot determine property name for member " ~ m ~ " of type " ~ T.stringof); static assert (!hasOneOfMemberAnnotations!(T, m, Column, Id, Generated, Generator, ManyToOne, ManyToMany, OneToOne), entityClassName ~ "." ~ propertyName ~ ": Embedded property cannot have Column, Id, Generated, OneToOne, ManyToOne, ManyToMany annotation"); - immutable string columnName = getColumnName!(T, m); + // While embedded properties have no column themselves, they can have a prefix for embedded properties. + immutable string columnName = getEmbeddedPropertyColumnPrefix!(T, m); // getColumnName!(T, m); immutable length = getColumnLength!(T, m); immutable bool hasNull = hasMemberAnnotation!(T, m, Null); immutable bool hasNotNull = hasMemberAnnotation!(T, m, NotNull); @@ -2976,6 +3006,10 @@ string getPropertyDef(T, string m)() { } } +/** + * Given a class T, generate D code as a string that defines an `EntityInfo` object that describes + * the class. + */ string getEntityDef(T)() { string res; string generatedGettersSetters; @@ -3192,14 +3226,17 @@ abstract class SchemaInfo : EntityMetaData { buf ~= data; } - public string getAllFieldListForUpdate(Dialect dialect, const EntityInfo ei, bool exceptKey = false) const { + // Obtains an SQL compatible list of all feldis for a given entity type. + public string getAllFieldListForUpdate( + Dialect dialect, const EntityInfo ei, bool exceptKey = false, + string columnPrefix="") const { string query; foreach(pi; ei) { if (pi.key && exceptKey) continue; if (pi.embedded) { auto emei = pi.referencedEntity; - appendCommaDelimitedList(query, getAllFieldListForUpdate(dialect, emei, exceptKey)); + appendCommaDelimitedList(query, getAllFieldListForUpdate(dialect, emei, exceptKey, pi.columnName == "" ? "" : pi.columnName ~ "_")); } else if (pi.oneToOne || pi.manyToOne) { if (pi.columnName != null) { // read FK column @@ -3208,20 +3245,20 @@ abstract class SchemaInfo : EntityMetaData { } else if (pi.oneToMany || pi.manyToMany) { // skip } else { - appendCommaDelimitedList(query, dialect.quoteIfNeeded(pi.columnName) ~ "=?"); + appendCommaDelimitedList(query, dialect.quoteIfNeeded(columnPrefix ~ pi.columnName) ~ "=?"); } } return query; } - override public string getAllFieldList(Dialect dialect, const EntityInfo ei, bool exceptKey = false) const { + override public string getAllFieldList(Dialect dialect, const EntityInfo ei, bool exceptKey = false, string columnPrefix="") const { string query; foreach(pi; ei) { if (pi.key && exceptKey) continue; if (pi.embedded) { auto emei = pi.referencedEntity; - appendCommaDelimitedList(query, getAllFieldList(dialect, emei, exceptKey)); + appendCommaDelimitedList(query, getAllFieldList(dialect, emei, exceptKey, pi.columnName == "" ? "" : pi.columnName ~ "_")); } else if (pi.oneToOne || pi.manyToOne) { if (pi.columnName != null) { // read FK column @@ -3230,7 +3267,7 @@ abstract class SchemaInfo : EntityMetaData { } else if (pi.oneToMany || pi.manyToMany) { // skip } else { - appendCommaDelimitedList(query, dialect.quoteIfNeeded(pi.columnName)); + appendCommaDelimitedList(query, dialect.quoteIfNeeded(columnPrefix ~ pi.columnName)); } } return query; @@ -3666,12 +3703,12 @@ class TableInfo { return columnNameMap[columnName]; } - private void appendColumns(const EntityInfo entity) { + private void appendColumns(const EntityInfo entity, string columnPrefix="") { foreach(pi; entity) { if (pi.embedded) { - appendColumns(pi.referencedEntity); + appendColumns(pi.referencedEntity, pi.columnName); } else if (pi.simple || (pi.columnName !is null)) { - addColumn(new ColumnInfo(this, pi)); + addColumn(new ColumnInfo(this, pi, columnPrefix)); if (pi.simple && pi.uniqueIndex !is null) //pi.unique) addUniqueColumnIndex(pi); } else if (pi.manyToMany) { @@ -3795,17 +3832,17 @@ class ColumnInfo { this.columnDefinition = table.schema.dialect.quoteIfNeeded(columnName) ~ " " ~ table.schema.dialect.getColumnTypeDefinition(null, referencedEntity.getKeyProperty()); } - this(TableInfo table, const PropertyInfo property) { + this(TableInfo table, const PropertyInfo property, string columnPrefix="") { this.table = table; this.property = property; - this.columnName = property.columnName; + this.columnName = columnPrefix == "" ? property.columnName : columnPrefix ~ "_" ~ property.columnName; assert(columnName !is null); if (property.manyToOne || property.oneToOne) { assert(property.columnName !is null); assert(property.referencedEntity !is null); - this.columnDefinition = table.schema.dialect.quoteIfNeeded(property.columnName) ~ " " ~ table.schema.dialect.getColumnTypeDefinition(property, property.referencedEntity.getKeyProperty()); + this.columnDefinition = table.schema.dialect.quoteIfNeeded(this.columnName) ~ " " ~ table.schema.dialect.getColumnTypeDefinition(property, property.referencedEntity.getKeyProperty()); } else { - this.columnDefinition = table.schema.dialect.getColumnDefinition(property); + this.columnDefinition = table.schema.dialect.quoteIfNeeded(this.columnName) ~ " " ~ table.schema.dialect.getColumnTypeDefinition(property); } } } diff --git a/source/hibernated/query.d b/source/hibernated/query.d index fb147f7..2d02fab 100644 --- a/source/hibernated/query.d +++ b/source/hibernated/query.d @@ -550,7 +550,7 @@ class QueryParser { enforceHelper!QuerySyntaxException((aliasCount == 1 && fieldCount == 0) || (aliasCount == 0 && fieldCount > 0), "You should either use single entity alias or one or more properties in SELECT clause. Don't mix objects with primitive fields"); return aliasCount > 0; } - + void parseWhereClause(int start, int end) { enforceHelper!QuerySyntaxException(start < end, "Invalid WHERE clause" ~ errorContext(tokens[start])); whereClause = new Token(tokens[start].pos, TokenType.Expression, tokens, start, end); @@ -568,7 +568,7 @@ class QueryParser { dropBraces(whereClause.children); //trace("after dropBraces\n" ~ whereClause.dump(0)); } - + void foldBraces(ref Token[] items) { while (true) { if (items.length == 0) @@ -631,7 +631,11 @@ class QueryParser { } } } - + + /** + * During the parsing of an HQL query, populates Tokens with contextual information such as + * EntityInfo, PropertyInfo, and more. + */ void convertFields(ref Token[] items) { while(true) { int p = -1; @@ -655,6 +659,7 @@ class QueryParser { idents ~= items[i + 1].text; } string fullName; + string columnPrefix; FromClauseItem a; if (items[p].type == TokenType.Alias) { a = findFromClauseByAlias(idents[0]); @@ -675,6 +680,7 @@ class QueryParser { pi = cast(PropertyInfo)ei.findProperty(propertyName); while (pi.embedded) { // loop to allow nested @Embedded enforceHelper!QuerySyntaxException(idents.length > 0, "Syntax error in WHERE condition - @Embedded property reference should include reference to @Embeddable property " ~ aliasName ~ errorContext(items[p])); + columnPrefix ~= pi.columnName == "" ? pi.columnName : pi.columnName ~ "_"; propertyName = idents[0]; idents.popFront(); pi = cast(PropertyInfo)pi.referencedEntity.findProperty(propertyName); @@ -697,9 +703,10 @@ class QueryParser { } enforceHelper!QuerySyntaxException(idents.length == 0, "Unexpected extra field name " ~ idents[0] ~ errorContext(items[p])); //trace("full name = " ~ fullName); - Token t = new Token(items[p].pos, TokenType.Field, fullName); + Token t = new Token(/+pos+/ items[p].pos, /+type+/ TokenType.Field, /+text+/ fullName); t.entity = cast(EntityInfo)ei; t.field = cast(PropertyInfo)pi; + t.columnPrefix = columnPrefix; t.from = a; replaceInPlace(items, p, lastp + 1, [t]); } @@ -831,14 +838,16 @@ class QueryParser { return -1; } - int addSelectSQL(Dialect dialect, ParsedQuery res, string tableName, bool first, const EntityInfo ei) { + int addSelectSQL(Dialect dialect, ParsedQuery res, string tableName, bool first, + const EntityInfo ei, string prefix="") { int colCount = 0; for(int j = 0; j < ei.getPropertyCount(); j++) { PropertyInfo f = cast(PropertyInfo)ei.getProperty(j); - string fieldName = f.columnName; + string fieldName = prefix ~ f.columnName; if (f.embedded) { // put embedded cols here - colCount += addSelectSQL(dialect, res, tableName, first && colCount == 0, f.referencedEntity); + colCount += addSelectSQL(dialect, res, tableName, first && colCount == 0, f.referencedEntity, + /*prefix*/ fieldName == "" ? "" : fieldName ~ "_"); continue; } else if (f.oneToOne) { } else { @@ -984,13 +993,14 @@ class QueryParser { } } } - + + // Converts a token into SQL and appends it to a WHERE section of a query. void addWhereCondition(Token t, int basePrecedency, Dialect dialect, ParsedQuery res) { if (t.type == TokenType.Expression) { addWhereCondition(t.children[0], basePrecedency, dialect, res); } else if (t.type == TokenType.Field) { string tableName = t.from.sqlAlias; - string fieldName = t.field.columnName; + string fieldName = t.columnPrefix ~ t.field.columnName; res.appendSpace(); res.appendSQL(tableName ~ "." ~ dialect.quoteIfNeeded(fieldName)); } else if (t.type == TokenType.Number) { @@ -1323,6 +1333,9 @@ class Token { EntityInfo entity; PropertyInfo field; FromClauseItem from; + // Embedded fields may have a prefix derived from `@Embedded` annotations on properties that + // contain them. + string columnPrefix; Token[] children; this(int pos, TokenType type, string text) { this.pos = pos; diff --git a/source/hibernated/session.d b/source/hibernated/session.d index 6492b8d..b95621c 100755 --- a/source/hibernated/session.d +++ b/source/hibernated/session.d @@ -848,14 +848,14 @@ class QueryImpl : Query ParameterValues params; this(SessionImpl sess, string queryString) { this.sess = sess; - //trace("QueryImpl(): HQL: " ~ queryString); - QueryParser parser = new QueryParser(sess.metaData, queryString); - //trace("parsing"); + //trace("QueryImpl(): HQL: " ~ queryString); + QueryParser parser = new QueryParser(sess.metaData, queryString); + //trace("parsing"); this.query = parser.makeSQL(sess.dialect); - //trace("SQL: " ~ this.query.sql); - params = query.createParams(); - //trace("exiting QueryImpl()"); - } + //trace("SQL: " ~ this.query.sql); + params = query.createParams(); + //trace("exiting QueryImpl()"); + } ///Get the query string. override string getQueryString() {