Trápení s MySql JDBC driverem

MySql databázi používáme jako standardní řešení datové vrstvy už hodně let. Prošli jsme si už pěknou řádku verzí JDBC ovladačů, ale jedna věc mě dostala vážně do kolen. Tak se pohodlně usaďte, protože dnešní příběh bude vážně dlouhý :-)

Žil byl v jedné firmě programátor starající se malou generickou knihovnu pracující s JDBC. Jednoho krásného rána se probudil s jednou nově reportovanou issue ve svém trackeru ... ale ne, takhle by to vyprávění trvalo opravdu hodně dlouho ... vše začalo touto krásnou exception:

java.sql.SQLException: Generated keys not requested. You need to specify  
Statement.RETURN_GENERATED_KEYS to Statement.executeUpdate() or 
Connection.prepareStatement(). at the execute command.

Pravděpodobně díky opravě jiné chyby (Issue 34185) změnil MySQL JDBC driver mezi verzemi 5.1.6 a 5.1.7 své chování při vracení hodnot GeneratedKeys (autoinkrementy) z update statementů. Ve starších verzích se hodnoty klíčů vracely i bez deklarace dodatečného flagu (RETURN_GENERATED_KEYS) při vytváření statementu, nově je tento flag vyžadován, jinak se vrací výše uvedená vyjímka. Chyba je dokumentována v Issue 41488. Od verze 5.1.7 je tedy nutné, pokud chcete dostat hodnoty autoinkrementů, specifikovat statement takto:


PreparedStatement ps = con.prepareStatement(
    "INSERT INTO `myTable` (name) VALUES (?)",
   PreparedStatement.RETURN_GENERATED_KEYS
);

Ok, podle specifikace jsme to měli dělat odjakživa (což ale jak jsem později zjistil stejně nešlo - viz. dále), jenže když to fungovalo bez flagu, tak se samozřejmě nikdo neobtěžoval to tak používat. Důležité ovšem také je, že k této zpětně nekompatibilní změně došlo mezi verzemi odlišující se jen třetím číslem (což obvykle představuje jen patche chyb), kde by to nikdo nečekal.

Historce ale ještě není konec - opravili jsme volání na správné a ouha, na starších ovladačích (pre 5.1.6) vracelo volání s použitím flagu NULL (dokumentováno v Issue 32101). Tj. knihovnu, kterou jsme chtěli zároveň pouštět jak se starší verzí driverů i s těmi novějšími, jsme museli naučit obojí chování. Poradili jsme si trošku naivně, zato funkčně:


PreparedStatement ps = con.prepareStatement(
   "INSERT INTO `myTable` (name) VALUES (?)",
   PreparedStatement.RETURN_GENERATED_KEYS
);
if (ps == null) {
   ps =con.prepareStatement(
      "INSERT INTO `myTable` (name) VALUES (?)"
      );
}

To nám chvíli vydrželo, než jsme na aktivnějších projektech začali pozorovat memory leaky. Po analýze heapdumpu jsme přišli na to, že nejvíc paměti zabírají Connection, které držely PreparedStatementy. Od tohoto zjištění nám už netrvalo dlouho si domyslet, že, přestože MySQL driver vrací NULL hodnotu při vytváření PreparedStatementu prvním způsobem, reálně objekt vznikne a je na connection zaregistrován!

No a to byl už trošku těžší oříšek - museli jsme tedy chování rozlišovat podle znalosti použitého JDBC driveru a ne adaptovat se podle chování této metody. Ještě že na třídě DatabaseMetaData existují metody: getDriverMajorVersion() a getDriverMinorVersion(). Jenže! Změna se nám odehrála až na třetím verzovacím čísle, takže ve všech případech dostaneme majorVersion=5, minorVersion=1. No a teď už je to vážně zajímavé ...

Chytli jsme se tedy metody getDriverVersion(), která sice vrací kompletní verzi nicméně jako řetězec. A v případě MySQL je ten řetězec vážně lahůdka:

mysql-connector-java-5.1.13 ( Revision: ${bzr.revision-id} )

Co ale nezvládneme pomocí regulárních výrazů, že? Takže jsme skončili u této verze metody:


public VersionDescriptor getDriverVersion(Connection connection) {
	try {
		DatabaseMetaData metaData = connection.getMetaData();
		Pattern versionPattern = Pattern.compile(".*?([0-9\\.]+).*?");
		Matcher matcher = versionPattern.matcher(metaData.getDriverVersion());
		if (matcher.matches()) {
			return new VersionDescriptor(matcher.group(1));
		} else {
			return null;
		}
	} catch (SQLException ex) {
		String msg = "Error retrieving JDBC driver version: " + ex.getLocalizedMessage();
		log.error(msg, ex);
		return null;
	}
}

A ve výsledném kódu nakonec používáme toto:


PreparedStatement ps;
if (AbstractDatabaseStorage.MYSQL.equals(platform)) {
	if (driverVersion != null && new VersionComparator().compare(driverVersion, MYSQL_NON_COMPATIBLE_VERSION) >= 0) {
		//update required by MySQL JDBC driver 5.1.7 and newer
		ps = con.prepareStatement(sqlBundle.getQuery(), PreparedStatement.RETURN_GENERATED_KEYS);
	} else {
		//correction of MySQL JDBC drivers older than 5.1.7 = issue #23017
		ps = con.prepareStatement(sqlBundle.getQuery());
	}
} else {
	ps = con.prepareStatement(sqlBundle.getQuery());
}

A to je už je vážně konec dnešní programátorské pohádky. A přitom je to jenom pár drobných chybek a nedomyšleností programátorů JDBC ovladače ... :-)