jOOQ

jOOQは「Java Object Oriented Querying」の略称で、JavaでSQLを書くための最良の方法を提供するライブラリです。データベースファーストのDSL(Domain Specific Language)により、SQLライクな流暢APIで型安全なクエリ構築を実現。複雑なSQLと型安全性の両立を実現する特殊なアプローチで、SQL知識を活かしながらJavaの型システムの恩恵を受けられます。データベーススキーマからJavaコードを生成し、コンパイル時にSQL構文の正確性を保証する革新的なアプローチを採用しています。

ORMJavaSQL優先型安全クエリビルダーコード生成

GitHub概要

jOOQ/jOOQ

jOOQ is the best way to write SQL in Java

ホームページ:https://www.jooq.org
スター6,446
ウォッチ153
フォーク1,216
作成日:2011年4月17日
言語:Java
ライセンス:Other

トピックス

code-generatordatabasedb2hibernatejavajdbcjdbc-utilitiesjooqjpamysqloracleormpostgresqlsqlsql-buildersql-formattersql-querysql-query-buildersql-query-formattersqlserver

スター履歴

jOOQ/jOOQ Star History
データ取得日時: 2025/7/17 00:43

ライブラリ

jOOQ

概要

jOOQは「Java Object Oriented Querying」の略称で、JavaでSQLを書くための最良の方法を提供するライブラリです。データベースファーストのDSL(Domain Specific Language)により、SQLライクな流暢APIで型安全なクエリ構築を実現。複雑なSQLと型安全性の両立を実現する特殊なアプローチで、SQL知識を活かしながらJavaの型システムの恩恵を受けられます。データベーススキーマからJavaコードを生成し、コンパイル時にSQL構文の正確性を保証する革新的なアプローチを採用しています。

詳細

jOOQ 2025年版は、SQLファーストアプローチによる型安全なデータベースアクセスの決定版として確固たる地位を築いています。従来のORMが抽象化により隠蔽するSQL制御権を開発者に完全に委ね、同時にJavaの型システムによる安全性を提供する画期的な設計思想。データベーススキーマから自動生成される型安全なクラス群により、存在しないテーブルやカラムへのアクセスをコンパイル時に検出可能。PostgreSQL、MySQL、Oracle、SQL Server等の主要DBを完全サポートし、方言固有の機能も活用できます。MULTISET演算子による入れ子クエリ、JSONサポート、ストアドプロシージャ実行など、エンタープライズレベルの高度な機能を提供します。

主な特徴

  • データベースファーストアプローチ: スキーマ定義からJavaコード自動生成
  • 型安全SQL DSL: コンパイル時のSQL構文チェックとテーブル・カラム検証
  • 豊富なSQL機能サポート: 複雑なJOIN、サブクエリ、ウィンドウ関数対応
  • マルチデータベース対応: 主要DBの方言と固有機能完全サポート
  • 高度なデータ構造: MULTISET、配列、JSON、XMLサポート
  • パフォーマンス最優先: 生SQLと同等の実行効率

メリット・デメリット

メリット

  • SQL知識を直接活かせる型安全なDSLによる高い開発効率
  • コンパイル時エラー検出による実行時例外の大幅削減
  • 複雑なクエリやパフォーマンスチューニングの完全制御
  • 豊富なSQL機能と各データベース固有機能の活用
  • ORMレイヤーのオーバーヘッドがない高いパフォーマンス
  • スキーマ変更の自動反映によるメンテナンス性向上

デメリット

  • 学習コストが高く、SQL知識が必須
  • データベーススキーマからのコード生成必須で初期設定が複雑
  • オブジェクト指向的なドメインモデル設計には不向き
  • スキーマ変更時のコード再生成が必要
  • 他ORMと比較してボイラープレートコード量が多い
  • ライセンスコストが発生する場合がある(商用版)

参考ページ

書き方の例

プロジェクト設定とコード生成

<!-- Maven pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.jooq</groupId>
        <artifactId>jooq</artifactId>
        <version>3.19.1</version>
    </dependency>
    <dependency>
        <groupId>org.jooq</groupId>
        <artifactId>jooq-meta</artifactId>
        <version>3.19.1</version>
    </dependency>
    <dependency>
        <groupId>org.jooq</groupId>
        <artifactId>jooq-codegen</artifactId>
        <version>3.19.1</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.jooq</groupId>
            <artifactId>jooq-codegen-maven</artifactId>
            <version>3.19.1</version>
            <configuration>
                <jdbc>
                    <driver>org.postgresql.Driver</driver>
                    <url>jdbc:postgresql://localhost:5432/testdb</url>
                    <user>username</user>
                    <password>password</password>
                </jdbc>
                <generator>
                    <database>
                        <name>org.jooq.meta.postgres.PostgresDatabase</name>
                        <inputSchema>public</inputSchema>
                    </database>
                    <target>
                        <packageName>com.example.generated</packageName>
                        <directory>target/generated-sources/jooq</directory>
                    </target>
                </generator>
            </configuration>
        </plugin>
    </plugins>
</build>
# コード生成実行
mvn jooq-codegen:generate

# Gradle設定例
dependencies {
    implementation 'org.jooq:jooq:3.19.1'
    jooqGenerator 'org.postgresql:postgresql:42.7.1'
}

jooq {
    configurations {
        main {
            generationTool {
                jdbc {
                    driver = 'org.postgresql.Driver'
                    url = 'jdbc:postgresql://localhost:5432/testdb'
                    user = 'username'
                    password = 'password'
                }
                generator {
                    database {
                        name = 'org.jooq.meta.postgres.PostgresDatabase'
                        inputSchema = 'public'
                    }
                    target {
                        packageName = 'com.example.generated'
                    }
                }
            }
        }
    }
}

基本的なクエリ操作(SELECT/INSERT/UPDATE/DELETE)

import static com.example.generated.Tables.*;
import static org.jooq.impl.DSL.*;

// DSLContext設定
DataSource ds = // DataSource設定
DSLContext dsl = DSL.using(ds, SQLDialect.POSTGRES);

// 基本的なSELECTクエリ
Result<Record> result = dsl
    .select()
    .from(USERS)
    .where(USERS.AGE.greaterThan(18))
    .orderBy(USERS.NAME)
    .fetch();

// 特定カラムの選択
List<String> names = dsl
    .select(USERS.NAME)
    .from(USERS)
    .where(USERS.EMAIL.like("%@example.com"))
    .fetch(USERS.NAME);

// JOINクエリ
Result<Record> joinResult = dsl
    .select(USERS.NAME, ORDERS.TOTAL_AMOUNT)
    .from(USERS)
    .innerJoin(ORDERS).on(USERS.ID.eq(ORDERS.USER_ID))
    .where(ORDERS.ORDER_DATE.greaterThan(LocalDate.of(2024, 1, 1)))
    .fetch();

// INSERT操作
UsersRecord newUser = dsl.newRecord(USERS);
newUser.setName("田中太郎");
newUser.setEmail("[email protected]");
newUser.setAge(30);
newUser.store(); // INSERT実行

// 別のINSERT方法
dsl.insertInto(USERS)
    .set(USERS.NAME, "佐藤花子")
    .set(USERS.EMAIL, "[email protected]")
    .set(USERS.AGE, 25)
    .execute();

// バッチINSERT
dsl.insertInto(USERS, USERS.NAME, USERS.EMAIL, USERS.AGE)
    .values("山田一郎", "[email protected]", 28)
    .values("鈴木二郎", "[email protected]", 32)
    .values("高橋三郎", "[email protected]", 29)
    .execute();

// UPDATE操作
int updatedCount = dsl
    .update(USERS)
    .set(USERS.AGE, USERS.AGE.add(1))
    .where(USERS.ID.eq(1))
    .execute();

// 条件付きUPDATE
dsl.update(USERS)
    .set(USERS.STATUS, "ACTIVE")
    .where(USERS.LAST_LOGIN.greaterThan(LocalDateTime.now().minusDays(30)))
    .execute();

// DELETE操作
int deletedCount = dsl
    .deleteFrom(USERS)
    .where(USERS.STATUS.eq("INACTIVE")
        .and(USERS.LAST_LOGIN.lessThan(LocalDateTime.now().minusYears(1))))
    .execute();

// レコード型での安全な操作
UsersRecord user = dsl
    .selectFrom(USERS)
    .where(USERS.ID.eq(1))
    .fetchOne();

if (user != null) {
    user.setAge(user.getAge() + 1);
    user.update(); // UPDATE実行
}

高度なクエリとMULTISET機能

// サブクエリの使用
List<UsersRecord> activeUsers = dsl
    .selectFrom(USERS)
    .where(USERS.ID.in(
        select(ORDERS.USER_ID)
        .from(ORDERS)
        .where(ORDERS.ORDER_DATE.greaterThan(LocalDate.now().minusDays(30)))
    ))
    .fetch();

// ウィンドウ関数
Result<Record4<String, Integer, Integer, Integer>> rankedUsers = dsl
    .select(
        USERS.NAME,
        USERS.AGE,
        count().over().as("total_users"),
        rowNumber().over().orderBy(USERS.AGE.desc()).as("age_rank")
    )
    .from(USERS)
    .orderBy(field("age_rank"))
    .fetch();

// Common Table Expression (WITH句)
Result<Record> withResult = dsl
    .with("young_users").as(
        select(USERS.ID, USERS.NAME, USERS.AGE)
        .from(USERS)
        .where(USERS.AGE.lessThan(30))
    )
    .select()
    .from(table(name("young_users")))
    .fetch();

// MULTISETを使用した階層データ取得
record Film(String title, List<Actor> actors, List<String> categories) {}
record Actor(String firstName, String lastName) {}

// MULTISET helper関数定義
public static <T> Field<List<T>> multisetArray(SelectFieldOrAsterisk<Record1<T>> select) {
    return field(
        "({0})",
        SQLDataType.CLOB.array(),
        select(coalesce(
            jsonArrayAgg(select),
            jsonArray()
        ))
    ).map(Convert.convert(Object[].class, (List<T>) null));
}

// 階層データ取得クエリ
List<Film> films = dsl
    .select(
        FILM.TITLE,
        multisetArray(
            select(ACTOR.FIRST_NAME, ACTOR.LAST_NAME)
            .from(FILM_ACTOR)
            .join(ACTOR).on(FILM_ACTOR.ACTOR_ID.eq(ACTOR.ACTOR_ID))
            .where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
        ).as("actors"),
        multisetArray(
            select(CATEGORY.NAME)
            .from(FILM_CATEGORY)
            .join(CATEGORY).on(FILM_CATEGORY.CATEGORY_ID.eq(CATEGORY.CATEGORY_ID))
            .where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
        ).as("categories")
    )
    .from(FILM)
    .orderBy(FILM.TITLE)
    .fetchInto(Film.class);

// 動的クエリ構築
Condition condition = trueCondition();

if (nameFilter != null) {
    condition = condition.and(USERS.NAME.containsIgnoreCase(nameFilter));
}
if (minAge != null) {
    condition = condition.and(USERS.AGE.greaterOrEqual(minAge));
}
if (emailDomain != null) {
    condition = condition.and(USERS.EMAIL.like("%" + emailDomain));
}

List<UsersRecord> filteredUsers = dsl
    .selectFrom(USERS)
    .where(condition)
    .orderBy(USERS.NAME)
    .fetch();

トランザクション管理とバッチ処理

// 基本的なトランザクション
dsl.transaction(configuration -> {
    DSLContext ctx = DSL.using(configuration);
    
    // ユーザー作成
    UsersRecord user = ctx.newRecord(USERS);
    user.setName("新規ユーザー");
    user.setEmail("[email protected]");
    user.store();
    
    // 注文作成
    OrdersRecord order = ctx.newRecord(ORDERS);
    order.setUserId(user.getId());
    order.setTotalAmount(new BigDecimal("1000.00"));
    order.store();
    
    // ポイント付与
    ctx.insertInto(USER_POINTS)
        .set(USER_POINTS.USER_ID, user.getId())
        .set(USER_POINTS.POINTS, 100)
        .execute();
});

// ネストトランザクション(セーブポイント)
dsl.transaction(configuration -> {
    DSLContext ctx = DSL.using(configuration);
    
    UsersRecord user = ctx.newRecord(USERS);
    user.setName("メインユーザー");
    user.store();
    
    try {
        ctx.transaction(nestedConfig -> {
            DSLContext nestedCtx = DSL.using(nestedConfig);
            
            // リスクの高い操作
            nestedCtx.update(USERS)
                .set(USERS.CREDIT_LIMIT, new BigDecimal("999999"))
                .where(USERS.ID.eq(user.getId()))
                .execute();
            
            // 何らかのチェック処理
            if (someValidationFails()) {
                throw new RuntimeException("検証失敗");
            }
        });
    } catch (RuntimeException e) {
        // ネストトランザクションのみロールバック
        logger.warn("ネスト処理失敗、メイン処理は継続: " + e.getMessage());
    }
});

// バッチ処理
List<UsersRecord> usersToUpdate = // 更新対象ユーザー取得
dsl.batchUpdate(usersToUpdate).execute();

// Bulk Insert with ON CONFLICT
dsl.insertInto(USERS)
    .columns(USERS.EMAIL, USERS.NAME, USERS.AGE)
    .values("[email protected]", "ユーザー1", 25)
    .values("[email protected]", "ユーザー2", 30)
    .values("[email protected]", "ユーザー3", 35)
    .onConflict(USERS.EMAIL)
    .doUpdate()
    .set(USERS.NAME, excluded(USERS.NAME))
    .set(USERS.AGE, excluded(USERS.AGE))
    .execute();

// バッチ操作のパフォーマンス測定
Stopwatch stopwatch = Stopwatch.createStarted();
int[] batchResult = dsl.batch(
    dsl.insertInto(USER_ACTIVITY)
        .set(USER_ACTIVITY.USER_ID, (Integer) null)
        .set(USER_ACTIVITY.ACTIVITY_TYPE, (String) null)
        .set(USER_ACTIVITY.TIMESTAMP, (LocalDateTime) null)
)
.bind(1, "LOGIN", LocalDateTime.now())
.bind(2, "VIEW_PAGE", LocalDateTime.now())
.bind(3, "PURCHASE", LocalDateTime.now())
.execute();

stopwatch.stop();
logger.info("バッチ処理完了: {}ms, 処理件数: {}", 
    stopwatch.elapsed(TimeUnit.MILLISECONDS), batchResult.length);

ストアドプロシージャとカスタム関数

// ストアドプロシージャ実行
Result<Record> procedureResult = dsl
    .select()
    .from(table(call("get_user_orders", 
        param("user_id", 123),
        param("start_date", LocalDate.of(2024, 1, 1))
    )))
    .fetch();

// カスタムSQL関数の定義
public static Field<BigDecimal> calculateDiscount(Field<BigDecimal> amount, Field<String> userType) {
    return field("calculate_discount({0}, {1})", SQLDataType.DECIMAL, amount, userType);
}

// カスタム関数の使用
List<Record3<String, BigDecimal, BigDecimal>> discountedOrders = dsl
    .select(
        USERS.NAME,
        ORDERS.TOTAL_AMOUNT,
        calculateDiscount(ORDERS.TOTAL_AMOUNT, USERS.USER_TYPE).as("discounted_amount")
    )
    .from(ORDERS)
    .join(USERS).on(ORDERS.USER_ID.eq(USERS.ID))
    .fetch();

// データベース固有機能の活用(PostgreSQL配列)
List<String> tags = List.of("java", "database", "orm");
dsl.insertInto(ARTICLES)
    .set(ARTICLES.TITLE, "jOOQ記事")
    .set(ARTICLES.CONTENT, "jOOQについて")
    .set(ARTICLES.TAGS, tags.toArray(new String[0])) // PostgreSQL配列
    .execute();

// JSON操作(PostgreSQL)
dsl.update(USERS)
    .set(USERS.PREFERENCES, 
        jsonObject(
            key("theme").value("dark"),
            key("language").value("ja"),
            key("notifications").value(true)
        ))
    .where(USERS.ID.eq(1))
    .execute();

// JSON検索
List<UsersRecord> darkThemeUsers = dsl
    .selectFrom(USERS)
    .where(USERS.PREFERENCES.extract("$.theme").eq("dark"))
    .fetch();

エラーハンドリングとログ出力

// SQLエラーハンドリング
try {
    dsl.insertInto(USERS)
        .set(USERS.EMAIL, "[email protected]") // 重複制約違反
        .execute();
} catch (DataAccessException e) {
    if (e.getCause() instanceof PSQLException) {
        PSQLException psql = (PSQLException) e.getCause();
        if ("23505".equals(psql.getSQLState())) { // UNIQUE_VIOLATION
            logger.warn("重複キーエラー: {}", psql.getMessage());
            // 重複処理ロジック
        }
    }
    throw e;
}

// クエリ実行時間監視
ExecuteListener listener = new DefaultExecuteListener() {
    @Override
    public void executeStart(ExecuteContext ctx) {
        logger.debug("Query start: {}", ctx.sql());
    }
    
    @Override
    public void executeEnd(ExecuteContext ctx) {
        logger.debug("Query end: {}ms", ctx.sqlExecutionTime());
        if (ctx.sqlExecutionTime() > 1000) {
            logger.warn("Slow query detected: {}ms - {}", 
                ctx.sqlExecutionTime(), ctx.sql());
        }
    }
};

DSLContext monitoredDsl = DSL.using(connection, SQLDialect.POSTGRES)
    .configuration()
    .derive(listener)
    .dsl();

// SQL出力とデバッグ
DSLContext debugDsl = DSL.using(connection, SQLDialect.POSTGRES)
    .configuration()
    .derive(new DefaultExecuteListener() {
        @Override
        public void renderEnd(ExecuteContext ctx) {
            logger.info("Generated SQL: {}", ctx.sql());
            logger.debug("Bind values: {}", Arrays.toString(ctx.bindings()));
        }
    })
    .dsl();

// 実行統計の取得
ExecuteContext stats = new DefaultExecuteContext();
Result<Record> result = dsl
    .select()
    .from(USERS)
    .where(USERS.STATUS.eq("ACTIVE"))
    .fetch();

logger.info("実行時間: {}ms, 取得件数: {}", 
    stats.sqlExecutionTime(), result.size());