Remove support for H2's web console

Closes gh-28590
pull/28862/head
Andy Wilkinson 3 years ago
parent 64bf33038d
commit a3c4059ee8

@ -1,105 +0,0 @@
/*
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.h2;
import java.sql.Connection;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.sql.DataSource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.h2.server.web.WebServlet;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.autoconfigure.h2.H2ConsoleProperties.Settings;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* {@link EnableAutoConfiguration Auto-configuration} for H2's web console.
*
* @author Andy Wilkinson
* @author Marten Deinum
* @author Stephane Nicoll
* @since 1.3.0
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass(WebServlet.class)
@ConditionalOnProperty(prefix = "spring.h2.console", name = "enabled", havingValue = "true")
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
@EnableConfigurationProperties(H2ConsoleProperties.class)
public class H2ConsoleAutoConfiguration {
private static final Log logger = LogFactory.getLog(H2ConsoleAutoConfiguration.class);
@Bean
public ServletRegistrationBean<WebServlet> h2Console(H2ConsoleProperties properties,
ObjectProvider<DataSource> dataSource) {
String path = properties.getPath();
String urlMapping = path + (path.endsWith("/") ? "*" : "/*");
ServletRegistrationBean<WebServlet> registration = new ServletRegistrationBean<>(new WebServlet(), urlMapping);
configureH2ConsoleSettings(registration, properties.getSettings());
if (logger.isInfoEnabled()) {
logDataSources(dataSource, path);
}
return registration;
}
private void logDataSources(ObjectProvider<DataSource> dataSource, String path) {
List<String> urls = dataSource.orderedStream().map((available) -> {
try (Connection connection = available.getConnection()) {
return "'" + connection.getMetaData().getURL() + "'";
}
catch (Exception ex) {
return null;
}
}).filter(Objects::nonNull).collect(Collectors.toList());
if (!urls.isEmpty()) {
StringBuilder sb = new StringBuilder("H2 console available at '").append(path).append("'. ");
String tmp = (urls.size() > 1) ? "Databases" : "Database";
sb.append(tmp).append(" available at ");
sb.append(String.join(", ", urls));
logger.info(sb.toString());
}
}
private void configureH2ConsoleSettings(ServletRegistrationBean<WebServlet> registration, Settings settings) {
if (settings.isTrace()) {
registration.addInitParameter("trace", "");
}
if (settings.isWebAllowOthers()) {
registration.addInitParameter("webAllowOthers", "");
}
if (settings.getWebAdminPassword() != null) {
registration.addInitParameter("webAdminPassword", settings.getWebAdminPassword());
}
}
}

@ -1,111 +0,0 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.h2;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.Assert;
/**
* Configuration properties for H2's console.
*
* @author Andy Wilkinson
* @author Marten Deinum
* @author Stephane Nicoll
* @since 1.3.0
*/
@ConfigurationProperties(prefix = "spring.h2.console")
public class H2ConsoleProperties {
/**
* Path at which the console is available.
*/
private String path = "/h2-console";
/**
* Whether to enable the console.
*/
private boolean enabled = false;
private final Settings settings = new Settings();
public String getPath() {
return this.path;
}
public void setPath(String path) {
Assert.notNull(path, "Path must not be null");
Assert.isTrue(path.length() > 1, "Path must have length greater than 1");
Assert.isTrue(path.startsWith("/"), "Path must start with '/'");
this.path = path;
}
public boolean getEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public Settings getSettings() {
return this.settings;
}
public static class Settings {
/**
* Whether to enable trace output.
*/
private boolean trace = false;
/**
* Whether to enable remote access.
*/
private boolean webAllowOthers = false;
/**
* Password to access preferences and tools of H2 Console.
*/
private String webAdminPassword;
public boolean isTrace() {
return this.trace;
}
public void setTrace(boolean trace) {
this.trace = trace;
}
public boolean isWebAllowOthers() {
return this.webAllowOthers;
}
public void setWebAllowOthers(boolean webAllowOthers) {
this.webAllowOthers = webAllowOthers;
}
public String getWebAdminPassword() {
return this.webAdminPassword;
}
public void setWebAdminPassword(String webAdminPassword) {
this.webAdminPassword = webAdminPassword;
}
}
}

@ -1,20 +0,0 @@
/*
* Copyright 2012-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Auto-configuration for H2's Console.
*/
package org.springframework.boot.autoconfigure.h2;

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,17 +16,8 @@
package org.springframework.boot.autoconfigure.security.servlet;
import java.util.function.Supplier;
import javax.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.h2.H2ConsoleProperties;
import org.springframework.boot.autoconfigure.security.StaticResourceLocation;
import org.springframework.boot.security.servlet.ApplicationContextRequestMatcher;
import org.springframework.boot.web.context.WebServerApplicationContext;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.context.WebApplicationContext;
/**
* Factory that can be used to create a {@link RequestMatcher} for commonly used paths.
@ -49,43 +40,4 @@ public final class PathRequest {
return StaticResourceRequest.INSTANCE;
}
/**
* Returns a matcher that includes the H2 console location. For example:
* <pre class="code">
* PathRequest.toH2Console()
* </pre>
* @return the configured {@link RequestMatcher}
*/
public static H2ConsoleRequestMatcher toH2Console() {
return new H2ConsoleRequestMatcher();
}
/**
* The request matcher used to match against h2 console path.
*/
public static final class H2ConsoleRequestMatcher extends ApplicationContextRequestMatcher<H2ConsoleProperties> {
private volatile RequestMatcher delegate;
private H2ConsoleRequestMatcher() {
super(H2ConsoleProperties.class);
}
@Override
protected boolean ignoreApplicationContext(WebApplicationContext applicationContext) {
return WebServerApplicationContext.hasServerNamespace(applicationContext, "management");
}
@Override
protected void initialized(Supplier<H2ConsoleProperties> h2ConsoleProperties) {
this.delegate = new AntPathRequestMatcher(h2ConsoleProperties.get().getPath() + "/**");
}
@Override
protected boolean matches(HttpServletRequest request, Supplier<H2ConsoleProperties> context) {
return this.delegate.matches(request);
}
}
}

@ -70,7 +70,6 @@ org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration,\
org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration,\
org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration,\
org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration,\
org.springframework.boot.autoconfigure.h2.H2ConsoleAutoConfiguration,\
org.springframework.boot.autoconfigure.hateoas.HypermediaAutoConfiguration,\
org.springframework.boot.autoconfigure.hazelcast.HazelcastAutoConfiguration,\
org.springframework.boot.autoconfigure.hazelcast.HazelcastJpaDependencyAutoConfiguration,\

@ -1,190 +0,0 @@
/*
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.h2;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link H2ConsoleAutoConfiguration}
*
* @author Andy Wilkinson
* @author Marten Deinum
* @author Stephane Nicoll
* @author Shraddha Yeole
*/
class H2ConsoleAutoConfigurationTests {
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(H2ConsoleAutoConfiguration.class));
@Test
void consoleIsDisabledByDefault() {
this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ServletRegistrationBean.class));
}
@Test
void propertyCanEnableConsole() {
this.contextRunner.withPropertyValues("spring.h2.console.enabled=true").run((context) -> {
assertThat(context).hasSingleBean(ServletRegistrationBean.class);
ServletRegistrationBean<?> registrationBean = context.getBean(ServletRegistrationBean.class);
assertThat(registrationBean.getUrlMappings()).contains("/h2-console/*");
assertThat(registrationBean.getInitParameters()).doesNotContainKey("trace");
assertThat(registrationBean.getInitParameters()).doesNotContainKey("webAllowOthers");
assertThat(registrationBean.getInitParameters()).doesNotContainKey("webAdminPassword");
});
}
@Test
void customPathMustBeginWithASlash() {
this.contextRunner.withPropertyValues("spring.h2.console.enabled=true", "spring.h2.console.path=custom")
.run((context) -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure()).isInstanceOf(BeanCreationException.class)
.hasMessageContaining("Failed to bind properties under 'spring.h2.console'");
});
}
@Test
void customPathWithTrailingSlash() {
this.contextRunner.withPropertyValues("spring.h2.console.enabled=true", "spring.h2.console.path=/custom/")
.run((context) -> {
assertThat(context).hasSingleBean(ServletRegistrationBean.class);
ServletRegistrationBean<?> registrationBean = context.getBean(ServletRegistrationBean.class);
assertThat(registrationBean.getUrlMappings()).contains("/custom/*");
});
}
@Test
void customPath() {
this.contextRunner.withPropertyValues("spring.h2.console.enabled=true", "spring.h2.console.path=/custom")
.run((context) -> {
assertThat(context).hasSingleBean(ServletRegistrationBean.class);
ServletRegistrationBean<?> registrationBean = context.getBean(ServletRegistrationBean.class);
assertThat(registrationBean.getUrlMappings()).contains("/custom/*");
});
}
@Test
void customInitParameters() {
this.contextRunner.withPropertyValues("spring.h2.console.enabled=true", "spring.h2.console.settings.trace=true",
"spring.h2.console.settings.web-allow-others=true",
"spring.h2.console.settings.web-admin-password=abcd").run((context) -> {
assertThat(context).hasSingleBean(ServletRegistrationBean.class);
ServletRegistrationBean<?> registrationBean = context.getBean(ServletRegistrationBean.class);
assertThat(registrationBean.getUrlMappings()).contains("/h2-console/*");
assertThat(registrationBean.getInitParameters()).containsEntry("trace", "");
assertThat(registrationBean.getInitParameters()).containsEntry("webAllowOthers", "");
assertThat(registrationBean.getInitParameters()).containsEntry("webAdminPassword", "abcd");
});
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void singleDataSourceUrlIsLoggedWhenOnlyOneAvailable(CapturedOutput output) {
this.contextRunner.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class))
.withPropertyValues("spring.h2.console.enabled=true").run((context) -> {
try (Connection connection = context.getBean(DataSource.class).getConnection()) {
assertThat(output).contains("H2 console available at '/h2-console'. Database available at '"
+ connection.getMetaData().getURL() + "'");
}
});
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void noDataSourceIsLoggedWhenNoneAvailable(CapturedOutput output) {
this.contextRunner.withUserConfiguration(FailingDataSourceConfiguration.class)
.withPropertyValues("spring.h2.console.enabled=true")
.run((context) -> assertThat(output).doesNotContain("H2 console available"));
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void allDataSourceUrlsAreLoggedWhenMultipleAvailable(CapturedOutput output) {
this.contextRunner
.withUserConfiguration(FailingDataSourceConfiguration.class, MultiDataSourceConfiguration.class)
.withPropertyValues("spring.h2.console.enabled=true").run((context) -> assertThat(output).contains(
"H2 console available at '/h2-console'. Databases available at 'someJdbcUrl', 'anotherJdbcUrl'"));
}
@Test
void h2ConsoleShouldNotFailIfDatabaseConnectionFails() {
this.contextRunner.withUserConfiguration(FailingDataSourceConfiguration.class)
.withPropertyValues("spring.h2.console.enabled=true")
.run((context) -> assertThat(context.isRunning()).isTrue());
}
@Configuration(proxyBeanMethods = false)
static class FailingDataSourceConfiguration {
@Bean
DataSource dataSource() throws SQLException {
DataSource dataSource = mock(DataSource.class);
given(dataSource.getConnection()).willThrow(IllegalStateException.class);
return dataSource;
}
}
@Configuration(proxyBeanMethods = false)
static class MultiDataSourceConfiguration {
@Bean
@Order(5)
DataSource anotherDataSource() throws SQLException {
return mockDataSource("anotherJdbcUrl");
}
@Bean
@Order(0)
DataSource someDataSource() throws SQLException {
return mockDataSource("someJdbcUrl");
}
private DataSource mockDataSource(String url) throws SQLException {
DataSource dataSource = mock(DataSource.class);
given(dataSource.getConnection()).willReturn(mock(Connection.class));
given(dataSource.getConnection().getMetaData()).willReturn(mock(DatabaseMetaData.class));
given(dataSource.getConnection().getMetaData().getURL()).willReturn(url);
return dataSource;
}
}
}

@ -1,51 +0,0 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.h2;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link H2ConsoleProperties}.
*
* @author Madhura Bhave
*/
class H2ConsolePropertiesTests {
@Test
void pathMustNotBeEmpty() {
H2ConsoleProperties properties = new H2ConsoleProperties();
assertThatIllegalArgumentException().isThrownBy(() -> properties.setPath(""))
.withMessageContaining("Path must have length greater than 1");
}
@Test
void pathMustHaveLengthGreaterThanOne() {
H2ConsoleProperties properties = new H2ConsoleProperties();
assertThatIllegalArgumentException().isThrownBy(() -> properties.setPath("/"))
.withMessageContaining("Path must have length greater than 1");
}
@Test
void customPathMustBeginWithASlash() {
H2ConsoleProperties properties = new H2ConsoleProperties();
assertThatIllegalArgumentException().isThrownBy(() -> properties.setPath("custom"))
.withMessageContaining("Path must start with '/'");
}
}

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,18 +16,8 @@
package org.springframework.boot.autoconfigure.security.servlet;
import javax.servlet.http.HttpServletRequest;
import org.assertj.core.api.AssertDelegateTarget;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.h2.H2ConsoleProperties;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
@ -42,76 +32,4 @@ class PathRequestTests {
assertThat(PathRequest.toStaticResources()).isInstanceOf(StaticResourceRequest.class);
}
@Test
void toH2ConsoleShouldMatchH2ConsolePath() {
RequestMatcher matcher = PathRequest.toH2Console();
assertMatcher(matcher).matches("/h2-console");
assertMatcher(matcher).matches("/h2-console/subpath");
assertMatcher(matcher).doesNotMatch("/js/file.js");
}
@Test
void toH2ConsoleWhenManagementContextShouldNeverMatch() {
RequestMatcher matcher = PathRequest.toH2Console();
assertMatcher(matcher, "management").doesNotMatch("/h2-console");
assertMatcher(matcher, "management").doesNotMatch("/h2-console/subpath");
assertMatcher(matcher, "management").doesNotMatch("/js/file.js");
}
private RequestMatcherAssert assertMatcher(RequestMatcher matcher) {
return assertMatcher(matcher, null);
}
private RequestMatcherAssert assertMatcher(RequestMatcher matcher, String serverNamespace) {
TestWebApplicationContext context = new TestWebApplicationContext(serverNamespace);
context.registerBean(ServerProperties.class);
context.registerBean(H2ConsoleProperties.class);
return assertThat(new RequestMatcherAssert(context, matcher));
}
static class RequestMatcherAssert implements AssertDelegateTarget {
private final WebApplicationContext context;
private final RequestMatcher matcher;
RequestMatcherAssert(WebApplicationContext context, RequestMatcher matcher) {
this.context = context;
this.matcher = matcher;
}
void matches(String path) {
matches(mockRequest(path));
}
private void matches(HttpServletRequest request) {
assertThat(this.matcher.matches(request)).as("Matches " + getRequestPath(request)).isTrue();
}
void doesNotMatch(String path) {
doesNotMatch(mockRequest(path));
}
private void doesNotMatch(HttpServletRequest request) {
assertThat(this.matcher.matches(request)).as("Does not match " + getRequestPath(request)).isFalse();
}
private MockHttpServletRequest mockRequest(String path) {
MockServletContext servletContext = new MockServletContext();
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);
MockHttpServletRequest request = new MockHttpServletRequest(servletContext);
request.setPathInfo(path);
return request;
}
private String getRequestPath(HttpServletRequest request) {
String url = request.getServletPath();
if (request.getPathInfo() != null) {
url += request.getPathInfo();
}
return url;
}
}
}

@ -231,8 +231,6 @@ boot-features-spring-data-jpa-repositories=features.sql.jpa-and-spring-data.repo
boot-features-creating-and-dropping-jpa-databases=features.sql.jpa-and-spring-data.creating-and-dropping
boot-features-jpa-in-web-environment=features.sql.jpa-and-spring-data.open-entity-manager-in-view
boot-features-data-jdbc=features.sql.jdbc
boot-features-sql-h2-console=features.sql.h2-web-console
boot-features-sql-h2-console-custom-path=features.sql.h2-web-console.custom-path
boot-features-jooq=features.sql.jooq
boot-features-jooq-codegen=features.sql.jooq.codegen
boot-features-jooq-dslcontext=features.sql.jooq.dslcontext

@ -315,28 +315,6 @@ TIP: For complete details of Spring Data JDBC, see the {spring-data-jdbc-docs}[r
[[data.sql.h2-web-console]]
=== Using H2's Web Console
The https://www.h2database.com[H2 database] provides a https://www.h2database.com/html/quickstart.html#h2_console[browser-based console] that Spring Boot can auto-configure for you.
The console is auto-configured when the following conditions are met:
* You are developing a servlet-based web application.
* `com.h2database:h2` is on the classpath.
* You are using <<using#using.devtools,Spring Boot's developer tools>>.
TIP: If you are not using Spring Boot's developer tools but would still like to make use of H2's console, you can configure the configprop:spring.h2.console.enabled[] property with a value of `true`.
NOTE: The H2 console is only intended for use during development, so you should take care to ensure that `spring.h2.console.enabled` is not set to `true` in production.
[[data.sql.h2-web-console.custom-path]]
==== Changing the H2 Console's Path
By default, the console is available at `/h2-console`.
You can customize the console's path by using the configprop:spring.h2.console.path[] property.
[[data.sql.jooq]]
=== Using jOOQ
jOOQ Object Oriented Querying (https://www.jooq.org/[jOOQ]) is a popular product from https://www.datageekery.com/[Data Geekery] which generates Java code from your database and lets you build type-safe SQL queries through its fluent API.

Loading…
Cancel
Save