diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8384c7b..b2ce146 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,12 +7,13 @@ on: branches: [ main ] jobs: - build: + matrix-build: + name: Build and test with Java ${{ matrix.java }} strategy: matrix: - java: [11, 17, 21] + java: ['17', '21'] env: - DEFAULT_JAVA: 17 + DEFAULT_JAVA: '17' runs-on: ubuntu-latest concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.java }} @@ -39,8 +40,8 @@ jobs: java-version: ${{ matrix.java }} cache: 'gradle' - - name: Build with Gradle - run: ./gradlew clean build + - name: Build with Java ${{ matrix.java }} + run: ./gradlew clean build --info -PjavaVersion=${{matrix.java}} - name: Publish Test Report uses: scacap/action-surefire-report@v1 @@ -55,3 +56,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + build: + name: Build and test with all Java versions + needs: matrix-build + runs-on: ubuntu-latest + continue-on-error: false + steps: + - run: echo Build successful diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs index f46d144..890832c 100644 --- a/.settings/org.eclipse.jdt.core.prefs +++ b/.settings/org.eclipse.jdt.core.prefs @@ -1,4 +1,5 @@ eclipse.preferences.version=1 +org.eclipse.jdt.core.classpath.outputOverlappingAnotherSource=ignore org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 org.eclipse.jdt.core.compiler.compliance=17 org.eclipse.jdt.core.compiler.source=17 diff --git a/.vscode/settings.json b/.vscode/settings.json index f95406c..90db8f2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,11 @@ "java.saveActions.organizeImports": true, "java.sources.organizeImports.starThreshold": 3, "java.sources.organizeImports.staticStarThreshold": 3, + "java.test.config": { + "vmArgs": [ + "-Djava.util.logging.config.file=src/main/resources/logging.properties" + ] + }, "sonarlint.connectedMode.project": { "connectionId": "itsallcode", "projectKey": "org.itsallcode:simple-jdbc" diff --git a/CHANGELOG.md b/CHANGELOG.md index a9561ae..734fe4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.5.0] - unreleased +## [0.6.0] - unreleased + +## [0.5.0] - 2023-11-26 + +- [PR #13](https://github.com/itsallcode/simple-jdbc/pull/13): Allow converting legacy types Timestamp, Time & Date ## [0.4.0] - 2023-10-21 diff --git a/README.md b/README.md index f4c0d88..263e6ac 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,13 @@ Wrapper to simplify working with JDBC. ## Usage +This project requires Java 17 or later. + Add dependency to your gradle project: -```gradle +```groovy dependencies { - implementation 'org.itsallcode:simple-jdbc:0.4.0' + implementation 'org.itsallcode:simple-jdbc:0.5.0' } ``` @@ -55,6 +57,7 @@ try (SimpleConnection connection = connectionFactory.create("jdbc:h2:mem:", "use ### Building Install to local maven repository: + ```sh ./gradlew clean publishToMavenLocal ``` diff --git a/build.gradle b/build.gradle index bffa1ad..fba2b47 100644 --- a/build.gradle +++ b/build.gradle @@ -1,28 +1,24 @@ plugins { id 'java-library' + id 'jvm-test-suite' id 'jacoco' + id 'jacoco-report-aggregation' id 'signing' id 'maven-publish' id 'org.sonarqube' version '4.4.1.3373' id "io.github.gradle-nexus.publish-plugin" version "1.3.0" - id 'com.github.ben-manes.versions' version '0.49.0' -} - -repositories { - mavenCentral() + id 'com.github.ben-manes.versions' version '0.50.0' } group 'org.itsallcode' -version = '0.4.0' +version = '0.5.0' dependencies { - testImplementation 'org.assertj:assertj-core:3.24.2' - testRuntimeOnly 'com.h2database:h2:2.2.224' } java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(getPropertyWithDefault('javaVersion', '17')) } withJavadocJar() withSourcesJar() @@ -36,23 +32,55 @@ javadoc { tasks.withType(JavaCompile) { options.compilerArgs << '-Xlint:all' + options.compilerArgs << '-Werror' options.encoding = 'UTF-8' } testing { suites { + configureEach { + useJUnitJupiter('5.10.1') + dependencies { + implementation project() + implementation libs.assertj + runtimeOnly libs.slf4jLogger + } + targets { + all { + testTask.configure { + if(logger.infoEnabled) { + testLogging.showStandardStreams = true + } + jvmArgs '-enableassertions' + systemProperty 'java.util.logging.config.file', file('src/test/resources/logging.properties') + } + } + } + } test { - useJUnitJupiter('5.10.0') + dependencies { + implementation libs.h2 + } + } + integrationTest(JvmTestSuite) { + testType = TestSuiteType.INTEGRATION_TEST + dependencies { + implementation libs.exasolTestcontainers + runtimeOnly libs.exasolJdbc + } + targets { + all { + testTask.configure { + shouldRunAfter(test) + } + } + } } } } -test { - if(logger.infoEnabled) { - testLogging.showStandardStreams = true - } - jvmArgs '-XX:+HeapDumpOnOutOfMemoryError', '-enableassertions' - systemProperty 'java.util.logging.config.file', file('src/test/resources/logging.properties') +tasks.named('check') { + dependsOn(testing.suites.integrationTest) } jacocoTestReport { @@ -68,7 +96,17 @@ sonar { } } -rootProject.tasks['sonarqube'].dependsOn(tasks['jacocoTestReport']) +rootProject.tasks['sonarqube'].dependsOn(tasks['testCodeCoverageReport'], tasks['integrationTestCodeCoverageReport']) + +def getPropertyWithDefault(String name, String defaultValue) { + if(project.hasProperty(name)) { + def value = project.property(name) + logger.info("Found value '${value}' for project property '${name}'") + return value + } + logger.info("Project property '${name}' not defined, using default '${defaultValue}'") + return defaultValue +} def getOptionalProperty(String name) { if(project.hasProperty(name)) { diff --git a/settings.gradle b/settings.gradle index c793928..1491f6e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,23 @@ rootProject.name = 'simple-jdbc' + + +dependencyResolutionManagement { + repositories { + mavenCentral() + } + versionCatalogs { + libs { + library('assertj', 'org.assertj:assertj-core:3.24.2') + library('h2', 'com.h2database:h2:2.2.224') + library('junitPioneer', 'org.junit-pioneer:junit-pioneer:2.2.0') + library('equalsverifier', 'nl.jqno.equalsverifier:equalsverifier:3.15.3') + library('tostringverifier', 'com.jparams:to-string-verifier:1.4.8') + library('hamcrest', 'org.hamcrest:hamcrest-all:1.3') + library('hamcrestResultSetMatcher', 'com.exasol:hamcrest-resultset-matcher:1.6.3') + library('mockito', 'org.mockito:mockito-core:5.7.0') + library('slf4jLogger', 'org.slf4j:slf4j-jdk14:2.0.9') + library('exasolJdbc', 'com.exasol:exasol-jdbc:7.1.20') + library('exasolTestcontainers', 'com.exasol:exasol-testcontainers:7.0.0') + } + } +} diff --git a/src/integrationTest/java/org/itsallcode/jdbc/LegacyTypeTest.java b/src/integrationTest/java/org/itsallcode/jdbc/LegacyTypeTest.java new file mode 100644 index 0000000..57806e3 --- /dev/null +++ b/src/integrationTest/java/org/itsallcode/jdbc/LegacyTypeTest.java @@ -0,0 +1,87 @@ +package org.itsallcode.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.stream.Stream; + +import org.itsallcode.jdbc.resultset.Row; +import org.itsallcode.jdbc.resultset.SimpleResultSet; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.exasol.containers.ExasolContainer; +import com.exasol.containers.ExasolService; + +class LegacyTypeITest { + + private static final ExasolContainer container = new ExasolContainer<>("8.23.1") + .withRequiredServices(ExasolService.JDBC).withReuse(true); + + @BeforeAll + static void startDb() { + container.start(); + } + + @AfterAll + static void stopDb() { + container.stop(); + } + + SimpleConnection connect() { + return ConnectionFactory.create(Context.builder().useModernTypes(true).build()).create(container.getJdbcUrl(), + container.getUsername(), container.getPassword()); + } + + @ParameterizedTest + @MethodSource("testTypes") + void type(final TypeTest test) { + try (SimpleResultSet result = connect() + .query("select cast('" + test.value() + "' as " + test.type() + ")")) { + final Object value = result.toList().get(0).getColumnValue(0).getValue(); + assertAll( + () -> assertThat(value.getClass()).isEqualTo(test.expectedValue().getClass()), + () -> assertThat(value).isEqualTo(test.expectedValue())); + } + } + + @ParameterizedTest + @MethodSource("testTypes") + void nullValue(final TypeTest test) { + try (SimpleResultSet result = connect() + .query("select cast(NULL as " + test.type() + ")")) { + assertThat(result.toList().get(0).getColumnValue(0).getValue()) + .isNull(); + } + } + + static Stream testTypes() { + return Stream.of( + typeTest("2023-11-25 16:18:46", "timestamp", Instant.parse("2023-11-25T16:18:46.0Z")), + typeTest("2023-11-25", "date", LocalDate.parse("2023-11-25")), + typeTest("5-3", "INTERVAL YEAR TO MONTH", "+05-03"), + typeTest("2 12:50:10.123", "INTERVAL DAY TO SECOND", "+02 12:50:10.123"), + typeTest("POINT(1 2)", "GEOMETRY", "POINT (1 2)"), + typeTest("550e8400-e29b-11d4-a716-446655440000", "HASHTYPE", "550e8400e29b11d4a716446655440000"), + typeTest("text", "VARCHAR(10)", "text"), + typeTest("text", "CHAR(10)", "text "), + typeTest("123.456", "DECIMAL", 123L), + typeTest("123.457", "DECIMAL(6,3)", BigDecimal.valueOf(123.457d)), + typeTest("123.458", "DOUBLE PRECISION", 123.458d), + typeTest("true", "BOOLEAN", true)); + } + + private static Arguments typeTest(final String value, final String type, final Object expectedValue) { + return Arguments.of(new TypeTest(value, type, expectedValue)); + } + + record TypeTest(String value, String type, Object expectedValue) { + + } +} diff --git a/src/main/java/org/itsallcode/jdbc/ConnectionFactory.java b/src/main/java/org/itsallcode/jdbc/ConnectionFactory.java index e81da90..d474823 100644 --- a/src/main/java/org/itsallcode/jdbc/ConnectionFactory.java +++ b/src/main/java/org/itsallcode/jdbc/ConnectionFactory.java @@ -1,8 +1,12 @@ package org.itsallcode.jdbc; import java.sql.*; +import java.util.List; import java.util.Properties; +import org.itsallcode.jdbc.resultset.Row; +import org.itsallcode.jdbc.resultset.RowMapper; + /** * This class connects to a database and returns new {@link SimpleConnection}s. */ @@ -14,12 +18,22 @@ private ConnectionFactory(final Context context) { } /** - * Create a new connection factory. + * Create a new connection factory with a default context. * * @return a new instance */ public static ConnectionFactory create() { - return new ConnectionFactory(new Context()); + return create(Context.builder().build()); + } + + /** + * Create a new connection factory with a custom context. + * + * @param context a custom context + * @return a new instance + */ + public static ConnectionFactory create(final Context context) { + return new ConnectionFactory(context); } /** @@ -65,4 +79,23 @@ private Connection createConnection(final String url, final Properties info) { throw new UncheckedSQLException("Error connecting to '" + url + "'", e); } } + + /** + * Create a {@link RowMapper} that creates generic {@link Row} objects. + * + * @return a new row mapper + */ + public RowMapper createGenericRowMapper() { + return RowMapper.createGenericRowMapper(context); + } + + /** + * Create a {@link RowMapper} that creates {@link List}s of simple column + * objects. + * + * @return a new row mapper + */ + public RowMapper> createListRowMapper() { + return RowMapper.createListRowMapper(context); + } } diff --git a/src/main/java/org/itsallcode/jdbc/Context.java b/src/main/java/org/itsallcode/jdbc/Context.java index aedeb75..401b83e 100644 --- a/src/main/java/org/itsallcode/jdbc/Context.java +++ b/src/main/java/org/itsallcode/jdbc/Context.java @@ -6,13 +6,24 @@ * This represents a context with configuration for the Simple JDBC framework. */ public class Context { + + private final boolean useModernTypes; + + private Context(final ContextBuilder builder) { + this.useModernTypes = builder.useModernTypes; + } + /** * Get the configured {@link ValueExtractorFactory}. * * @return value extractor factory */ public ValueExtractorFactory getValueExtractorFactory() { - return ValueExtractorFactory.create(); + if (useModernTypes) { + return ValueExtractorFactory.createModernType(); + } else { + return ValueExtractorFactory.create(); + } } /** @@ -23,4 +34,44 @@ public ValueExtractorFactory getValueExtractorFactory() { public ParameterMapper getParameterMapper() { return ParameterMapper.create(); } + + /** + * Create a new builder for {@link Context} objects. + * + * @return a new builder + */ + public static ContextBuilder builder() { + return new ContextBuilder(); + } + + /** + * A builder for {@link Context} objects. + */ + public static class ContextBuilder { + private boolean useModernTypes = false; + + private ContextBuilder() { + } + + /** + * Configure the context to convert legacy types returned by the result set to + * modern types. + * + * @param useModernTypes {@code true} to convert legacy types + * @return {@code this} for fluent programming + */ + public ContextBuilder useModernTypes(final boolean useModernTypes) { + this.useModernTypes = useModernTypes; + return this; + } + + /** + * Build a new context. + * + * @return a new context + */ + public Context build() { + return new Context(this); + } + } } diff --git a/src/main/java/org/itsallcode/jdbc/ParamConverter.java b/src/main/java/org/itsallcode/jdbc/ParamConverter.java index d0b8bee..7fd9c52 100644 --- a/src/main/java/org/itsallcode/jdbc/ParamConverter.java +++ b/src/main/java/org/itsallcode/jdbc/ParamConverter.java @@ -3,6 +3,8 @@ /** * This converts a domain object to types supported by the database when * inserting rows. + * + * @param row type */ @FunctionalInterface public interface ParamConverter { diff --git a/src/main/java/org/itsallcode/jdbc/SimpleConnection.java b/src/main/java/org/itsallcode/jdbc/SimpleConnection.java index 5f9e641..582363f 100644 --- a/src/main/java/org/itsallcode/jdbc/SimpleConnection.java +++ b/src/main/java/org/itsallcode/jdbc/SimpleConnection.java @@ -61,7 +61,7 @@ public void executeStatement(final String sql) { * @return the result set */ public SimpleResultSet query(final String sql) { - return query(sql, new GenericRowMapper(context)); + return query(sql, RowMapper.createGenericRowMapper(context)); } /** @@ -90,7 +90,7 @@ public SimpleResultSet query(final String sql, final RowMapper rowMapp */ public SimpleResultSet query(final String sql, final PreparedStatementSetter preparedStatementSetter, final RowMapper rowMapper) { - LOG.fine(() -> "Executing query '" + sql + "'..."); + LOG.finest(() -> "Executing query '" + sql + "'..."); final SimplePreparedStatement statement = prepareStatement(sql); statement.setValues(preparedStatementSetter); return statement.executeQuery(rowMapper); @@ -147,7 +147,7 @@ private PreparedStatement prepare(final String sql) { try { return connection.prepareStatement(sql); } catch (final SQLException e) { - throw new UncheckedSQLException("Error preparing statement '" + sql + "'", e); + throw new UncheckedSQLException("Error preparing statement '" + sql + "': " + e.getMessage(), e); } } diff --git a/src/main/java/org/itsallcode/jdbc/SimpleParameterMetaData.java b/src/main/java/org/itsallcode/jdbc/SimpleParameterMetaData.java index 070b58d..9cf330b 100644 --- a/src/main/java/org/itsallcode/jdbc/SimpleParameterMetaData.java +++ b/src/main/java/org/itsallcode/jdbc/SimpleParameterMetaData.java @@ -87,7 +87,16 @@ private static ParameterNullable of(final int mode) { } /** - * A parameter. + * A parameter for a prepared statement. + * + * @param className class name of the parameter type + * @param type JDBC type of the parameter + * @param typeName name of the parameter type + * @param mode parameter mode + * @param precision parameter precision + * @param scale parameter scale + * @param signed {@code true} if the parameter is signed + * @param nullable nullability of the parameter */ public static record Parameter(String className, int type, String typeName, ParameterMode mode, int precision, int scale, boolean signed, ParameterNullable nullable) { diff --git a/src/main/java/org/itsallcode/jdbc/identifier/Identifier.java b/src/main/java/org/itsallcode/jdbc/identifier/Identifier.java index 7ac5ba5..9741c40 100644 --- a/src/main/java/org/itsallcode/jdbc/identifier/Identifier.java +++ b/src/main/java/org/itsallcode/jdbc/identifier/Identifier.java @@ -1,5 +1,7 @@ package org.itsallcode.jdbc.identifier; +import java.util.Arrays; + /** * Represents a database identifier, e.g. of a table or a schema. */ @@ -13,4 +15,24 @@ public interface Identifier { @Override String toString(); + + /** + * Create a new {@link SimpleIdentifier}. + * + * @param id the id + * @return a new {@link SimpleIdentifier} + */ + static Identifier simple(final String id) { + return SimpleIdentifier.of(id); + } + + /** + * Create a new {@link QualifiedIdentifier} from the given parts. + * + * @param id parts of the ID + * @return a new {@link QualifiedIdentifier} + */ + static Identifier qualified(final String... id) { + return QualifiedIdentifier.of(Arrays.stream(id).map(SimpleIdentifier::of).toArray(SimpleIdentifier[]::new)); + } } diff --git a/src/main/java/org/itsallcode/jdbc/resultset/GenericRowMapper.java b/src/main/java/org/itsallcode/jdbc/resultset/GenericRowMapper.java index d015498..7bfa9eb 100644 --- a/src/main/java/org/itsallcode/jdbc/resultset/GenericRowMapper.java +++ b/src/main/java/org/itsallcode/jdbc/resultset/GenericRowMapper.java @@ -2,13 +2,17 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; import org.itsallcode.jdbc.Context; +import org.itsallcode.jdbc.UncheckedSQLException; +import org.itsallcode.jdbc.resultset.SimpleMetaData.ColumnMetaData; /** * This {@link RowMapper} converts a row to the generic {@link Row} type. */ -public class GenericRowMapper implements RowMapper { +class GenericRowMapper implements RowMapper { private final Context context; private ResultSetRowBuilder rowBuilder; @@ -17,7 +21,7 @@ public class GenericRowMapper implements RowMapper { * * @param context context */ - public GenericRowMapper(final Context context) { + GenericRowMapper(final Context context) { this.context = context; } @@ -28,4 +32,33 @@ public Row mapRow(final ResultSet resultSet, final int rowNum) throws SQLExcepti } return rowBuilder.buildRow(resultSet, rowNum); } + + private class ResultSetRowBuilder { + private final SimpleMetaData metadata; + + private ResultSetRowBuilder(final SimpleMetaData metaData) { + this.metadata = metaData; + } + + private Row buildRow(final ResultSet resultSet, final int rowIndex) { + final List columns = metadata.getColumns(); + final List fields = new ArrayList<>(columns.size()); + for (final ColumnMetaData column : columns) { + final ColumnValue field = getField(resultSet, column, rowIndex); + fields.add(field); + } + return new Row(rowIndex, fields); + } + + private ColumnValue getField(final ResultSet resultSet, final ColumnMetaData column, final int rowIndex) { + try { + return column.getValueExtractor().extractValue(resultSet, column.getColumnIndex()); + } catch (final SQLException e) { + throw new UncheckedSQLException( + "Error extracting value for row " + rowIndex + " / column " + column + ": " + e.getMessage(), + e); + } + } + } + } diff --git a/src/main/java/org/itsallcode/jdbc/resultset/ListRowMapper.java b/src/main/java/org/itsallcode/jdbc/resultset/ListRowMapper.java new file mode 100644 index 0000000..c8eb868 --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/resultset/ListRowMapper.java @@ -0,0 +1,64 @@ +package org.itsallcode.jdbc.resultset; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import org.itsallcode.jdbc.Context; +import org.itsallcode.jdbc.UncheckedSQLException; +import org.itsallcode.jdbc.resultset.SimpleMetaData.ColumnMetaData; + +/** + * This {@link RowMapper} converts a row to a {@link List} of simple column + * values. + */ +class ListRowMapper implements RowMapper> { + private final Context context; + private ResultSetRowBuilder rowBuilder; + + /** + * Create a new instance. + * + * @param context context + */ + ListRowMapper(final Context context) { + this.context = context; + } + + @Override + public List mapRow(final ResultSet resultSet, final int rowNum) throws SQLException { + if (rowBuilder == null) { + rowBuilder = new ResultSetRowBuilder(SimpleMetaData.create(resultSet.getMetaData(), context)); + } + return rowBuilder.buildRow(resultSet, rowNum); + } + + private class ResultSetRowBuilder { + private final SimpleMetaData metadata; + + private ResultSetRowBuilder(final SimpleMetaData metaData) { + this.metadata = metaData; + } + + private List buildRow(final ResultSet resultSet, final int rowIndex) { + final List columns = metadata.getColumns(); + final List fields = new ArrayList<>(columns.size()); + for (final ColumnMetaData column : columns) { + final Object field = getField(resultSet, column, rowIndex); + fields.add(field); + } + return fields; + } + + private Object getField(final ResultSet resultSet, final ColumnMetaData column, final int rowIndex) { + try { + return column.getValueExtractor().extractValue(resultSet, column.getColumnIndex()).getValue(); + } catch (final SQLException e) { + throw new UncheckedSQLException( + "Error extracting value for row " + rowIndex + " / column " + column + ": " + e.getMessage(), + e); + } + } + } +} diff --git a/src/main/java/org/itsallcode/jdbc/resultset/ModernValueExtractorFactor.java b/src/main/java/org/itsallcode/jdbc/resultset/ModernValueExtractorFactor.java new file mode 100644 index 0000000..76c861b --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/resultset/ModernValueExtractorFactor.java @@ -0,0 +1,40 @@ +package org.itsallcode.jdbc.resultset; + +import java.sql.*; +import java.util.Calendar; +import java.util.TimeZone; + +class ModernValueExtractorFactor implements ValueExtractorFactory { + private final Calendar utcCalendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + + @Override + public ResultSetValueExtractor create(final ColumnType type) { + return (resultSet, columnIndex) -> new ColumnValue(type, getValue(type, resultSet, columnIndex)); + } + + private Object getValue(final ColumnType type, final ResultSet resultSet, final int columnIndex) + throws SQLException { + final Object object = resultSet.getObject(columnIndex); + if (resultSet.wasNull()) { + return null; + } + switch (type.getJdbcType()) { + case Types.TIMESTAMP: + final Timestamp timestamp = resultSet.getTimestamp(columnIndex, utcCalendar); + return timestamp.toInstant(); + case Types.TIME: + final Time time = resultSet.getTime(columnIndex); + return time.toLocalTime(); + case Types.DATE: + return resultSet.getDate(columnIndex, utcCalendar).toLocalDate(); + case Types.CLOB: + final Clob clob = resultSet.getClob(columnIndex); + return clob.getSubString(1, (int) clob.length()); + case Types.BLOB: + final Blob blob = resultSet.getBlob(columnIndex); + return blob.getBytes(1, (int) blob.length()); + default: + return object; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/itsallcode/jdbc/resultset/OriginalValueExtractorFactor.java b/src/main/java/org/itsallcode/jdbc/resultset/OriginalValueExtractorFactor.java new file mode 100644 index 0000000..45b523d --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/resultset/OriginalValueExtractorFactor.java @@ -0,0 +1,15 @@ +package org.itsallcode.jdbc.resultset; + +import java.sql.ResultSet; +import java.sql.SQLException; + +class OriginalValueExtractorFactor implements ValueExtractorFactory { + @Override + public ResultSetValueExtractor create(final ColumnType type) { + return (resultSet, columnIndex) -> new ColumnValue(type, getValue(resultSet, columnIndex)); + } + + private Object getValue(final ResultSet resultSet, final int columnIndex) throws SQLException { + return resultSet.getObject(columnIndex); + } +} \ No newline at end of file diff --git a/src/main/java/org/itsallcode/jdbc/resultset/ResultSetRowBuilder.java b/src/main/java/org/itsallcode/jdbc/resultset/ResultSetRowBuilder.java deleted file mode 100644 index 19add4a..0000000 --- a/src/main/java/org/itsallcode/jdbc/resultset/ResultSetRowBuilder.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.itsallcode.jdbc.resultset; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -import org.itsallcode.jdbc.UncheckedSQLException; -import org.itsallcode.jdbc.resultset.SimpleMetaData.ColumnMetaData; - -class ResultSetRowBuilder { - private final SimpleMetaData metadata; - - ResultSetRowBuilder(final SimpleMetaData metaData) { - this.metadata = metaData; - } - - Row buildRow(final ResultSet resultSet, final int rowIndex) { - final List columns = metadata.getColumns(); - final List fields = new ArrayList<>(columns.size()); - for (final ColumnMetaData column : columns) { - final ColumnValue field = getField(resultSet, column, rowIndex); - fields.add(field); - } - return new Row(rowIndex, fields); - } - - private ColumnValue getField(final ResultSet resultSet, final ColumnMetaData column, final int rowIndex) { - try { - return column.getValueExtractor().extractValue(resultSet, column.getColumnIndex()); - } catch (final SQLException e) { - throw new UncheckedSQLException( - "Error extracting value for row " + rowIndex + " / column " + column + ": " + e.getMessage(), e); - } - } -} diff --git a/src/main/java/org/itsallcode/jdbc/resultset/RowMapper.java b/src/main/java/org/itsallcode/jdbc/resultset/RowMapper.java index f2670ea..439a6cc 100644 --- a/src/main/java/org/itsallcode/jdbc/resultset/RowMapper.java +++ b/src/main/java/org/itsallcode/jdbc/resultset/RowMapper.java @@ -2,6 +2,9 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.List; + +import org.itsallcode.jdbc.Context; /** * Converts a single row from a {@link ResultSet} to a generic row type. @@ -20,4 +23,25 @@ public interface RowMapper { * @throws SQLException if accessing the result set fails */ T mapRow(ResultSet resultSet, int rowNum) throws SQLException; + + /** + * Create a {@link RowMapper} that creates generic {@link Row} objects. + * + * @param context the context + * @return a new row mapper + */ + static RowMapper createGenericRowMapper(final Context context) { + return new GenericRowMapper(context); + } + + /** + * Create a {@link RowMapper} that creates {@link List}s of simple column + * objects. + * + * @param context the context + * @return a new row mapper + */ + static RowMapper> createListRowMapper(final Context context) { + return new ListRowMapper(context); + } } diff --git a/src/main/java/org/itsallcode/jdbc/resultset/SimpleResultSet.java b/src/main/java/org/itsallcode/jdbc/resultset/SimpleResultSet.java index ae95510..b7f4f3c 100644 --- a/src/main/java/org/itsallcode/jdbc/resultset/SimpleResultSet.java +++ b/src/main/java/org/itsallcode/jdbc/resultset/SimpleResultSet.java @@ -10,6 +10,8 @@ /** * This class wraps a {@link ResultSet} and allows easy iteration via * {@link Iterator}, {@link List} or {@link Stream}. + * + * @param row type */ public class SimpleResultSet implements AutoCloseable, Iterable { private final ResultSet resultSet; diff --git a/src/main/java/org/itsallcode/jdbc/resultset/ValueExtractorFactory.java b/src/main/java/org/itsallcode/jdbc/resultset/ValueExtractorFactory.java index 56cf260..453230c 100644 --- a/src/main/java/org/itsallcode/jdbc/resultset/ValueExtractorFactory.java +++ b/src/main/java/org/itsallcode/jdbc/resultset/ValueExtractorFactory.java @@ -1,38 +1,41 @@ package org.itsallcode.jdbc.resultset; import java.sql.ResultSet; -import java.sql.SQLException; /** * This factory creates {@link ResultSetValueExtractor} based on * {@link ColumnType}. */ -public class ValueExtractorFactory { +public interface ValueExtractorFactory { - private ValueExtractorFactory() { - // Use static factory method - } + /** + * Create a new {@link ResultSetValueExtractor}. + * + * @param type the column type + * @return the new value extractor + */ + ResultSetValueExtractor create(final ColumnType type); /** - * Create a new factory. + * Create a new factory that does not convert values but returns them as + * returned by {@link ResultSet#getObject(int)}. * * @return a new factory */ public static ValueExtractorFactory create() { - return new ValueExtractorFactory(); + return new OriginalValueExtractorFactor(); } /** - * Create a new {@link ResultSetValueExtractor}. + * Create a new factory that convert values as follows: + *
    + *
  • {@link java.sql.Timestamp} -> {@link java.time.Instant}
  • + *
  • {@link java.sql.Date} -> {@link java.time.LocalDate}
  • + *
* - * @param type the column type - * @return the new value extractor + * @return a new factory */ - public ResultSetValueExtractor create(final ColumnType type) { - return (resultSet, columnIndex) -> new ColumnValue(type, getValue(resultSet, columnIndex)); - } - - private Object getValue(final ResultSet resultSet, final int columnIndex) throws SQLException { - return resultSet.getObject(columnIndex); + public static ValueExtractorFactory createModernType() { + return new ModernValueExtractorFactor(); } } diff --git a/src/test/java/org/itsallcode/jdbc/H2TestFixture.java b/src/test/java/org/itsallcode/jdbc/H2TestFixture.java index b4e55d4..3efff6a 100644 --- a/src/test/java/org/itsallcode/jdbc/H2TestFixture.java +++ b/src/test/java/org/itsallcode/jdbc/H2TestFixture.java @@ -1,11 +1,15 @@ package org.itsallcode.jdbc; -public class H2TestFixture -{ - private static final ConnectionFactory connectionFactory = ConnectionFactory.create(); +public class H2TestFixture { + public static SimpleConnection createMemConnection() { + return createMemConnection(Context.builder().build()); + } + + public static SimpleConnection createMemConnectionWithModernTypes() { + return createMemConnection(Context.builder().useModernTypes(true).build()); + } - public static SimpleConnection createMemConnection() - { - return connectionFactory.create("jdbc:h2:mem:"); + public static SimpleConnection createMemConnection(final Context context) { + return ConnectionFactory.create(context).create("jdbc:h2:mem:"); } } diff --git a/src/test/java/org/itsallcode/jdbc/H2TypeTest.java b/src/test/java/org/itsallcode/jdbc/H2TypeTest.java new file mode 100644 index 0000000..2e55080 --- /dev/null +++ b/src/test/java/org/itsallcode/jdbc/H2TypeTest.java @@ -0,0 +1,84 @@ +package org.itsallcode.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.math.BigDecimal; +import java.time.*; +import java.util.UUID; +import java.util.stream.Stream; + +import org.h2.api.Interval; +import org.itsallcode.jdbc.resultset.Row; +import org.itsallcode.jdbc.resultset.SimpleResultSet; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class H2TypeTest { + @ParameterizedTest + @MethodSource("testTypes") + void types(final TypeTest test) { + try (SimpleConnection connection = H2TestFixture.createMemConnectionWithModernTypes(); + SimpleResultSet result = connection + .query("select cast('" + test.value() + "' as " + test.type() + ")")) { + final Object value = result.toList().get(0).getColumnValue(0).getValue(); + assertAll( + () -> assertThat(value.getClass()).isEqualTo(test.expectedValue().getClass()), + () -> assertThat(value).isEqualTo(test.expectedValue())); + } + } + + @ParameterizedTest + @MethodSource("testTypes") + void nullValues(final TypeTest test) { + try (SimpleConnection connection = H2TestFixture.createMemConnectionWithModernTypes(); + SimpleResultSet result = connection + .query("select cast(NULL as " + test.type() + ")")) { + final Object value = result.toList().get(0).getColumnValue(0).getValue(); + assertThat(value).isNull(); + } + } + + static Stream testTypes() { + return Stream.of( + typeTest("text", "CHARACTER(5)", "text "), + typeTest("text", "CHARACTER VARYING", "text"), + typeTest("text", "CHARACTER LARGE OBJECT", "text"), + typeTest("text", "VARCHAR_IGNORECASE", "text"), + typeTest("text", "BINARY(5)", new byte[] { 116, 101, 120, 116, 0 }), + typeTest("text", "BINARY VARYING", new byte[] { 116, 101, 120, 116 }), + typeTest("text", "BINARY LARGE OBJECT", new byte[] { 116, 101, 120, 116 }), + typeTest("true", "BOOLEAN", true), + typeTest("42", "TINYINT", 42), + typeTest("42", "SMALLINT", 42), + typeTest("42", "INTEGER", 42), + typeTest("42", "BIGINT", 42L), + typeTest("42", "NUMERIC", BigDecimal.valueOf(42)), + typeTest("42.13", "REAL", 42.13f), + typeTest("123.458", "DOUBLE PRECISION", 123.458d), + typeTest("123.458", "DECFLOAT", BigDecimal.valueOf(123.458d)), + + typeTest("2023-11-25", "date", LocalDate.parse("2023-11-25")), + typeTest("13:24:40", "TIME", LocalTime.parse("13:24:40")), + typeTest("2023-11-25 16:18:46", "timestamp", Instant.parse("2023-11-25T16:18:46.0Z")), + + typeTest("10", "INTERVAL YEAR", Interval.ofYears(10)), + typeTest("a", "ENUM('a', 'b')", "a"), + typeTest("POINT(1 2)", "GEOMETRY", "POINT (1 2)"), + typeTest("{\"key\":42}", "JSON", + new byte[] { 34, 123, 92, 34, 107, 101, 121, 92, 34, 58, 52, 50, 125, 34 }), + typeTest("550e8400-e29b-11d4-a716-446655440000", "UUID", + UUID.fromString("550e8400-e29b-11d4-a716-446655440000")) + + ); + } + + private static Arguments typeTest(final String value, final String type, final Object expectedValue) { + return Arguments.of(new TypeTest(value, type, expectedValue)); + } + + record TypeTest(String value, String type, Object expectedValue) { + + } +} diff --git a/src/test/java/org/itsallcode/jdbc/SimpleConnectionITest.java b/src/test/java/org/itsallcode/jdbc/SimpleConnectionITest.java index 8d704fa..c4a25c3 100644 --- a/src/test/java/org/itsallcode/jdbc/SimpleConnectionITest.java +++ b/src/test/java/org/itsallcode/jdbc/SimpleConnectionITest.java @@ -8,11 +8,10 @@ import java.sql.SQLException; import java.sql.Types; -import java.util.Iterator; -import java.util.List; -import java.util.NoSuchElementException; +import java.util.*; import java.util.stream.Stream; +import org.itsallcode.jdbc.identifier.Identifier; import org.itsallcode.jdbc.resultset.Row; import org.itsallcode.jdbc.resultset.SimpleResultSet; import org.junit.jupiter.api.Test; @@ -46,7 +45,7 @@ void executeScript() { } @Test - void executeQuery() { + void executeQueryWithGenericRowMapper() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { connection.executeScript("CREATE TABLE TEST(ID INT, NAME VARCHAR(255));" + "insert into test (id, name) values (1, 'test');"); @@ -60,6 +59,21 @@ void executeQuery() { } } + @Test + void executeQueryWithListRowMapper() { + final ConnectionFactory factory = ConnectionFactory.create(); + try (SimpleConnection connection = factory.create("jdbc:h2:mem:")) { + connection.executeScript("CREATE TABLE TEST(ID INT, NAME VARCHAR(255));" + + "insert into test (id, name) values (1, 'test');"); + try (SimpleResultSet> resultSet = connection.query("select * from test", + factory.createListRowMapper())) { + final List> rows = resultSet.toList(); + assertThat(rows).hasSize(1); + assertThat(rows.get(0)).containsExactly(1, "test"); + } + } + } + @Test void executeQueryEmptyResult() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { @@ -149,4 +163,19 @@ void batchInsert() { assertThat(result.get(0).getColumnValue(0).getValue()).isEqualTo(3L); } } + + @Test + void insert() { + try (SimpleConnection connection = H2TestFixture.createMemConnection()) { + connection.executeScript("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + connection.insert(Identifier.simple("TEST"), List.of(Identifier.simple("ID"), Identifier.simple("NAME")), + ParamConverter.identity(), + Stream.of(new Object[] { 1, "a" }, new Object[] { 2, "b" }, new Object[] { 3, "c" })); + + final List> result = connection.query("select * from test").stream() + .map(row -> row.getColumnValues().stream().map(value -> value.getValue()).toList()).toList(); + assertThat(result).hasSize(3); + assertThat(result).isEqualTo(List.of(List.of(1, "a"), List.of(2, "b"), List.of(3, "c"))); + } + } }