Core Concepts
Configuration
All data access in Pyranid is performed through a Database
instance. You can configure it by providing your own implementations of "hook" interfaces to a builder at construction time. Once the Database
is created, it's threadsafe and designed to be shared across your app.
By design, there is a 1:1 relationship between your Database
and its javax.sql.DataSource
. For larger systems, you might opt to have one instance that points to a writable primary and another that points to a load-balanced pool of read replicas.
Minimal Setup
This approach uses out-of-the-box Pyranid defaults. It gets you up and running quickly to kick the tires.
// Create a Database backed by a DataSource
DataSource dataSource = obtainDataSource();
Database database = Database.withDataSource(dataSource).build();
// Create a Database backed by a DataSource
DataSource dataSource = obtainDataSource();
Database database = Database.withDataSource(dataSource).build();
Customized Setup
This example shows all of the different "hook" interfaces you might use to customize behavior.
In production systems, at a minimum you will want to provide your own StatementLogger
to keep an eye on your SQL and its performance. If your app relies on Dependency Injection, you'll want to wire in your own InstanceProvider
.
// Controls how Pyranid creates instances of objects
// that represent ResultSet rows
InstanceProvider instanceProvider = new InstanceProvider() {
@Override
@Nonnull
public <T> T provide(
@Nonnull StatementContext<T> statementContext,
@Nonnull Class<T> instanceType
) {
// You might have your DI framework vend regular object instances
return guiceInjector.getInstance(instanceType);
}
@Override
@Nonnull
public <T extends Record> T provideRecord(
@Nonnull StatementContext<T> statementContext,
@Nonnull Class<T> recordType,
@Nullable Object... initargs
) {
// If you use Record types, customize their instantiation here.
// Default implementation will use the canonical constructor
return super.provideRecord(statementContext, recordType, initargs);
}
};
// Handles copying data from a ResultSet row to an instance of the specified type.
// Supports JavaBeans, records, and standard JDK types out-of-the-box.
// Plan caching (on by default) trades memory for faster mapping of wide ResultSets.
// Normalization locale should match the language of your database tables/column names.
// CustomColumnMappers supply "surgical" overrides to handle custom types
ResultSetMapper resultSetMapper =
ResultSetMapper.withPlanCachingEnabled(false)
.normalizationLocale(Locale.forLanguageTag("pt-BR"))
.customColumnMappers(List.of(new CustomColumnMapper() {
@Nonnull
@Override
public Boolean appliesTo(@Nonnull TargetType targetType) {
// Can also apply to parameterized types, e.g.
// targetType.matchesParameterizedType(List.class, UUID.class) for List<UUID>
return targetType.matchesClass(Money.class);
}
@Nonnull
@Override
public MappingResult map(
@Nonnull StatementContext<?> statementContext,
@Nonnull ResultSet resultSet,
@Nonnull Object resultSetValue,
@Nonnull TargetType targetType,
@Nonnull Integer columnIndex,
@Nullable String columnLabel,
@Nonnull InstanceProvider instanceProvider
) {
// Convert the ResultSet column's value to the "appliesTo" Java type.
// Don't need null checks - this method is only invoked when the value is non-null
String moneyAsString = resultSetValue.toString();
Money money = Money.parse(moneyAsString);
// Or return MappingResult.fallback() to indicate "I don't want to do custom mapping"
// and Pyranid will fall back to the registered ResultSetMapper's mapping behavior
return MappingResult.of(money);
}
}))
.build();
// Binds parameters to a SQL PreparedStatement.
// CustomParameterBinders supply "surgical" overrides to handle custom types.
// Here, we transform Money instances into a DB-friendly string representation
PreparedStatementBinder preparedStatementBinder =
PreparedStatementBinder.withCustomParameterBinders(List.of(
new CustomParameterBinder() {
@Nonnull
@Override
public Boolean appliesTo(@Nonnull TargetType targetType) {
return targetType.matchesClass(Money.class);
}
@Nonnull
@Override
public BindingResult bind(
@Nonnull StatementContext<?> statementContext,
@Nonnull PreparedStatement preparedStatement,
@Nonnull Integer parameterIndex,
@Nonnull Object parameter
) throws SQLException {
// Convert Money to a string representation for binding.
// Don't need null checks - this method is only invoked when the value is non-null
Money money = (Money) parameter;
String moneyAsString = money.stringValue();
// Bind to the PreparedStatement
preparedStatement.setString(parameterIndex, moneyAsString);
// Or return BindingResult.fallback() to indicate "I don't want to do custom binding"
// and Pyranid will fall back to the registered PreparedStatementBinder's binding behavior
return BindingResult.handled();
}
}
));
// Optionally logs SQL statements
StatementLogger statementLogger = new StatementLogger() {
@Override
public void log(@Nonnull StatementLog statementLog) {
// Send to whatever output sink you'd like
out.println(statementLog);
}
};
// Useful if your JVM's default timezone doesn't match your Database's default timezone
ZoneId timeZone = ZoneId.of("UTC");
Database customDatabase = Database.withDataSource(dataSource)
.timeZone(timeZone)
.instanceProvider(instanceProvider)
.resultSetMapper(resultSetMapper)
.preparedStatementBinder(preparedStatementBinder)
.statementLogger(statementLogger)
.build();
// Controls how Pyranid creates instances of objects
// that represent ResultSet rows
InstanceProvider instanceProvider = new InstanceProvider() {
@Override
@Nonnull
public <T> T provide(
@Nonnull StatementContext<T> statementContext,
@Nonnull Class<T> instanceType
) {
// You might have your DI framework vend regular object instances
return guiceInjector.getInstance(instanceType);
}
@Override
@Nonnull
public <T extends Record> T provideRecord(
@Nonnull StatementContext<T> statementContext,
@Nonnull Class<T> recordType,
@Nullable Object... initargs
) {
// If you use Record types, customize their instantiation here.
// Default implementation will use the canonical constructor
return super.provideRecord(statementContext, recordType, initargs);
}
};
// Handles copying data from a ResultSet row to an instance of the specified type.
// Supports JavaBeans, records, and standard JDK types out-of-the-box.
// Plan caching (on by default) trades memory for faster mapping of wide ResultSets.
// Normalization locale should match the language of your database tables/column names.
// CustomColumnMappers supply "surgical" overrides to handle custom types
ResultSetMapper resultSetMapper =
ResultSetMapper.withPlanCachingEnabled(false)
.normalizationLocale(Locale.forLanguageTag("pt-BR"))
.customColumnMappers(List.of(new CustomColumnMapper() {
@Nonnull
@Override
public Boolean appliesTo(@Nonnull TargetType targetType) {
// Can also apply to parameterized types, e.g.
// targetType.matchesParameterizedType(List.class, UUID.class) for List<UUID>
return targetType.matchesClass(Money.class);
}
@Nonnull
@Override
public MappingResult map(
@Nonnull StatementContext<?> statementContext,
@Nonnull ResultSet resultSet,
@Nonnull Object resultSetValue,
@Nonnull TargetType targetType,
@Nonnull Integer columnIndex,
@Nullable String columnLabel,
@Nonnull InstanceProvider instanceProvider
) {
// Convert the ResultSet column's value to the "appliesTo" Java type.
// Don't need null checks - this method is only invoked when the value is non-null
String moneyAsString = resultSetValue.toString();
Money money = Money.parse(moneyAsString);
// Or return MappingResult.fallback() to indicate "I don't want to do custom mapping"
// and Pyranid will fall back to the registered ResultSetMapper's mapping behavior
return MappingResult.of(money);
}
}))
.build();
// Binds parameters to a SQL PreparedStatement.
// CustomParameterBinders supply "surgical" overrides to handle custom types.
// Here, we transform Money instances into a DB-friendly string representation
PreparedStatementBinder preparedStatementBinder =
PreparedStatementBinder.withCustomParameterBinders(List.of(
new CustomParameterBinder() {
@Nonnull
@Override
public Boolean appliesTo(@Nonnull TargetType targetType) {
return targetType.matchesClass(Money.class);
}
@Nonnull
@Override
public BindingResult bind(
@Nonnull StatementContext<?> statementContext,
@Nonnull PreparedStatement preparedStatement,
@Nonnull Integer parameterIndex,
@Nonnull Object parameter
) throws SQLException {
// Convert Money to a string representation for binding.
// Don't need null checks - this method is only invoked when the value is non-null
Money money = (Money) parameter;
String moneyAsString = money.stringValue();
// Bind to the PreparedStatement
preparedStatement.setString(parameterIndex, moneyAsString);
// Or return BindingResult.fallback() to indicate "I don't want to do custom binding"
// and Pyranid will fall back to the registered PreparedStatementBinder's binding behavior
return BindingResult.handled();
}
}
));
// Optionally logs SQL statements
StatementLogger statementLogger = new StatementLogger() {
@Override
public void log(@Nonnull StatementLog statementLog) {
// Send to whatever output sink you'd like
out.println(statementLog);
}
};
// Useful if your JVM's default timezone doesn't match your Database's default timezone
ZoneId timeZone = ZoneId.of("UTC");
Database customDatabase = Database.withDataSource(dataSource)
.timeZone(timeZone)
.instanceProvider(instanceProvider)
.resultSetMapper(resultSetMapper)
.preparedStatementBinder(preparedStatementBinder)
.statementLogger(statementLogger)
.build();
Interfaces
InstanceProvider
- When results are returned from a query, Pyranid needs to create an instance of an object to hold data for each row in the resultset. The default implementation assumes you have either a Record instantiable via its canonical constructor or an Object instantiable via
Class<T>::getDeclaredConstructor(java.lang.Class...)
. In production systems, you might find it useful to have a Dependency Injection library like Google Guice vend these instances.
ResultSetMapper
- For each instance created by your
InstanceProvider
, data from the resultset needs to be mapped to it. By default, reflection is used to determine how to map DB column names to Java property names, withsnake_case
being automatically mapped tocamelCase
. More details are available in the ResultSet Mapping documentation.
PreparedStatementBinder
- Parameterized SQL statments are ultimately converted to
java.sql.PreparedStatement
and passed along to your JDBC driver for processing. This interface allows you to control per-parameter how binding should be performed. More details are available in the Parameter Binding documentation.
StatementLogger
- Every database operation has diagnostic information captured and made accessible via a
StatementLog
instance. You might want to write log slow queries to a logging system like Logback, send tracing information to New Relic or just write everything to stdout - it's up to you! More details are available in the Logging and Diagnostics documentation.
Addendum: Obtaining a DataSource
Pyranid works with any javax.sql.DataSource implementation. If you have the freedom to choose, HikariCP (application-level) and PgBouncer (external; Postgres-only) are good options.
// HikariCP
DataSource hikariDataSource = new HikariDataSource(new HikariConfig() {{
setJdbcUrl("jdbc:postgresql://localhost:5432/my-database");
setUsername("example");
setPassword("secret");
setConnectionInitSql("SET TIME ZONE 'UTC'");
}});
// PgBouncer (using Postgres' JDBC driver-provided DataSource impl)
DataSource pgBouncerDataSource = new PGSimpleDataSource() {{
setServerNames(new String[] {"localhost"});
setPortNumber(5432);
setDatabaseName("my-database");
setUser("example");
setPassword("secret");
setPreferQueryMode(PreferQueryMode.SIMPLE);
}};
// HikariCP
DataSource hikariDataSource = new HikariDataSource(new HikariConfig() {{
setJdbcUrl("jdbc:postgresql://localhost:5432/my-database");
setUsername("example");
setPassword("secret");
setConnectionInitSql("SET TIME ZONE 'UTC'");
}});
// PgBouncer (using Postgres' JDBC driver-provided DataSource impl)
DataSource pgBouncerDataSource = new PGSimpleDataSource() {{
setServerNames(new String[] {"localhost"});
setPortNumber(5432);
setDatabaseName("my-database");
setUser("example");
setPassword("secret");
setPreferQueryMode(PreferQueryMode.SIMPLE);
}};