Pyranid Logo

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();

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();

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, with snake_case being automatically mapped to camelCase. 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);
}};
Previous
Contributing