cc-codeconductor 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +319 -0
- package/dist/index.js +12741 -0
- package/package.json +51 -0
- package/policy.yml +124 -0
- package/presets/claude/CLAUDE.md +598 -0
- package/presets/claude/commands/cc/feature.md +115 -0
- package/presets/claude/commands/cc/fix.md +121 -0
- package/presets/claude/commands/cc/refactor.md +148 -0
- package/presets/claude/commands/cc/review.md +126 -0
- package/presets/claude/commands/cc/tdd-cycle.md +226 -0
- package/presets/claude/commands/cc/test-plan.md +138 -0
- package/presets/claude/settings.json +37 -0
- package/presets/claude/skills/api-versioning/SKILL.md +389 -0
- package/presets/claude/skills/django-orm/SKILL.md +455 -0
- package/presets/claude/skills/django-testing/SKILL.md +409 -0
- package/presets/claude/skills/jpa-postgres/SKILL.md +618 -0
- package/presets/claude/skills/python/SKILL.md +606 -0
- package/presets/claude/skills/python-django-stack/SKILL.md +492 -0
- package/presets/claude/skills/python-fastapi-stack/SKILL.md +457 -0
- package/presets/claude/skills/spring-boot-feature/SKILL.md +558 -0
- package/presets/claude/skills/spring-boot-kotlin/SKILL.md +400 -0
- package/presets/claude/skills/sqlalchemy/SKILL.md +466 -0
- package/presets/claude/skills/testing-strategy/SKILL.md +479 -0
- package/presets/codex/AGENTS.md +883 -0
- package/presets/codex/README.md +102 -0
- package/presets/codex/skills/api-versioning/SKILL.md +389 -0
- package/presets/codex/skills/django-orm/SKILL.md +455 -0
- package/presets/codex/skills/django-testing/SKILL.md +409 -0
- package/presets/codex/skills/jpa-postgres/SKILL.md +618 -0
- package/presets/codex/skills/python/SKILL.md +606 -0
- package/presets/codex/skills/python-django-stack/SKILL.md +492 -0
- package/presets/codex/skills/python-fastapi-stack/SKILL.md +457 -0
- package/presets/codex/skills/spring-boot-feature/SKILL.md +558 -0
- package/presets/codex/skills/spring-boot-kotlin/SKILL.md +400 -0
- package/presets/codex/skills/sqlalchemy/SKILL.md +466 -0
- package/presets/codex/skills/testing-strategy/SKILL.md +479 -0
- package/presets/opencode/README.md +191 -0
- package/presets/opencode/agents/architect.md +133 -0
- package/presets/opencode/agents/docs.md +113 -0
- package/presets/opencode/agents/implementer.md +116 -0
- package/presets/opencode/agents/orchestrator.md +248 -0
- package/presets/opencode/agents/repo-explorer.md +119 -0
- package/presets/opencode/agents/reviewer.md +130 -0
- package/presets/opencode/agents/task-coach.md +103 -0
- package/presets/opencode/agents/tester.md +269 -0
- package/presets/opencode/commands/cc-feature.md +114 -0
- package/presets/opencode/commands/cc-fix.md +123 -0
- package/presets/opencode/commands/cc-refactor.md +148 -0
- package/presets/opencode/commands/cc-review.md +141 -0
- package/presets/opencode/commands/cc-tdd-cycle.md +225 -0
- package/presets/opencode/commands/cc-test-plan.md +144 -0
- package/presets/opencode/opencode.jsonc +89 -0
- package/presets/opencode/prompts/v0.1.0/architect.md +213 -0
- package/presets/opencode/prompts/v0.1.0/docs.md +181 -0
- package/presets/opencode/prompts/v0.1.0/implementer.md +154 -0
- package/presets/opencode/prompts/v0.1.0/orchestrator.md +169 -0
- package/presets/opencode/prompts/v0.1.0/repo-explorer.md +102 -0
- package/presets/opencode/prompts/v0.1.0/reviewer.md +183 -0
- package/presets/opencode/prompts/v0.1.0/task-coach.md +142 -0
- package/presets/opencode/prompts/v0.1.0/tester.md +160 -0
- package/presets/opencode/prompts/v0.2.0/architect.md +219 -0
- package/presets/opencode/prompts/v0.2.0/docs.md +187 -0
- package/presets/opencode/prompts/v0.2.0/implementer.md +160 -0
- package/presets/opencode/prompts/v0.2.0/orchestrator.md +238 -0
- package/presets/opencode/prompts/v0.2.0/repo-explorer.md +108 -0
- package/presets/opencode/prompts/v0.2.0/reviewer.md +190 -0
- package/presets/opencode/prompts/v0.2.0/task-coach.md +153 -0
- package/presets/opencode/prompts/v0.2.0/tester.md +249 -0
- package/presets/opencode/skills/api-versioning/SKILL.md +388 -0
- package/presets/opencode/skills/astro/SKILL.md +313 -0
- package/presets/opencode/skills/code-review/SKILL.md +202 -0
- package/presets/opencode/skills/django-orm/SKILL.md +455 -0
- package/presets/opencode/skills/django-testing/SKILL.md +408 -0
- package/presets/opencode/skills/django-uv/SKILL.md +400 -0
- package/presets/opencode/skills/jpa-postgres/SKILL.md +617 -0
- package/presets/opencode/skills/nextjs-typescript/SKILL.md +385 -0
- package/presets/opencode/skills/python/SKILL.md +605 -0
- package/presets/opencode/skills/python-django-stack/SKILL.md +491 -0
- package/presets/opencode/skills/python-fastapi-stack/SKILL.md +456 -0
- package/presets/opencode/skills/security/SKILL.md +376 -0
- package/presets/opencode/skills/spring-boot-feature/SKILL.md +557 -0
- package/presets/opencode/skills/spring-boot-kotlin/SKILL.md +399 -0
- package/presets/opencode/skills/spring-boot-testing-strategy/SKILL.md +470 -0
- package/presets/opencode/skills/sqlalchemy/SKILL.md +465 -0
- package/presets/opencode/skills/testing-tdd/SKILL.md +586 -0
- package/src/presets/council/council.yml +59 -0
- package/src/presets/manifests/claude.yml +23 -0
- package/src/presets/manifests/codex.yml +12 -0
- package/src/presets/manifests/opencode.yml +18 -0
- package/src/presets/models/claude.yml +37 -0
- package/src/presets/models/codex.yml +38 -0
- package/src/presets/models/opencode.yml +37 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: jpa-postgres
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
name: JPA + PostgreSQL
|
|
5
|
+
description:
|
|
6
|
+
Provides expert knowledge of JPA entity design, relationship mapping, Flyway
|
|
7
|
+
migrations, and PostgreSQL-specific optimizations.
|
|
8
|
+
|
|
9
|
+
compatibility:
|
|
10
|
+
tools: [claude, codex, opencode]
|
|
11
|
+
stacks:
|
|
12
|
+
languages: [kotlin, java]
|
|
13
|
+
frameworks: [spring-boot, spring-data-jpa, hibernate]
|
|
14
|
+
databases: [postgresql]
|
|
15
|
+
|
|
16
|
+
risk:
|
|
17
|
+
level: high
|
|
18
|
+
can_execute_shell: false
|
|
19
|
+
can_modify_files: true
|
|
20
|
+
requires_network: false
|
|
21
|
+
|
|
22
|
+
inputs:
|
|
23
|
+
- source_files
|
|
24
|
+
- migration scripts
|
|
25
|
+
- entity classes
|
|
26
|
+
- repository interfaces
|
|
27
|
+
|
|
28
|
+
outputs:
|
|
29
|
+
- JPA entity classes
|
|
30
|
+
- repository interfaces
|
|
31
|
+
- Flyway migration scripts
|
|
32
|
+
- JPQL and native queries
|
|
33
|
+
- integration test classes
|
|
34
|
+
|
|
35
|
+
quality:
|
|
36
|
+
reviewed_by: codeconductor-core
|
|
37
|
+
version: 0.1.0
|
|
38
|
+
---
|
|
39
|
+
# JPA + PostgreSQL
|
|
40
|
+
|
|
41
|
+
## Entity Design
|
|
42
|
+
|
|
43
|
+
### Primary Keys
|
|
44
|
+
|
|
45
|
+
Use UUIDs. Do not use auto-increment integers as public-facing identifiers.
|
|
46
|
+
|
|
47
|
+
```kotlin
|
|
48
|
+
@Entity
|
|
49
|
+
@Table(name = "users")
|
|
50
|
+
class User(
|
|
51
|
+
@Id
|
|
52
|
+
@GeneratedValue(strategy = GenerationType.UUID)
|
|
53
|
+
val id: UUID = UUID.randomUUID(),
|
|
54
|
+
|
|
55
|
+
@Column(name = "email", nullable = false, unique = true, length = 255)
|
|
56
|
+
var email: String,
|
|
57
|
+
|
|
58
|
+
@Column(name = "name", nullable = false, length = 100)
|
|
59
|
+
var name: String
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
UUID generation strategy `GenerationType.UUID` is available in Hibernate 6+
|
|
64
|
+
(Spring Boot 3+). For earlier versions, use `@UuidGenerator` from Hibernate or
|
|
65
|
+
generate manually.
|
|
66
|
+
|
|
67
|
+
### Auditing
|
|
68
|
+
|
|
69
|
+
Enable automatic timestamp management with Spring Data auditing.
|
|
70
|
+
|
|
71
|
+
```kotlin
|
|
72
|
+
// Enable in main application class or a @Configuration class
|
|
73
|
+
@EnableJpaAuditing
|
|
74
|
+
@SpringBootApplication
|
|
75
|
+
class Application
|
|
76
|
+
|
|
77
|
+
// Base class for auditable entities
|
|
78
|
+
@MappedSuperclass
|
|
79
|
+
@EntityListeners(AuditingEntityListener::class)
|
|
80
|
+
abstract class AuditableEntity {
|
|
81
|
+
|
|
82
|
+
@Column(name = "created_at", nullable = false, updatable = false)
|
|
83
|
+
@CreatedDate
|
|
84
|
+
lateinit var createdAt: Instant
|
|
85
|
+
|
|
86
|
+
@Column(name = "updated_at", nullable = false)
|
|
87
|
+
@LastModifiedDate
|
|
88
|
+
lateinit var updatedAt: Instant
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Entity extends the base class
|
|
92
|
+
@Entity
|
|
93
|
+
@Table(name = "users")
|
|
94
|
+
class User(
|
|
95
|
+
@Id @GeneratedValue(strategy = GenerationType.UUID)
|
|
96
|
+
val id: UUID = UUID.randomUUID(),
|
|
97
|
+
|
|
98
|
+
@Column(name = "email", nullable = false, unique = true)
|
|
99
|
+
var email: String,
|
|
100
|
+
|
|
101
|
+
var name: String
|
|
102
|
+
) : AuditableEntity()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Soft Delete
|
|
106
|
+
|
|
107
|
+
Do not delete rows — mark them as deleted and filter them transparently.
|
|
108
|
+
|
|
109
|
+
```kotlin
|
|
110
|
+
@Entity
|
|
111
|
+
@Table(name = "users")
|
|
112
|
+
@SQLDelete(sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?")
|
|
113
|
+
@FilterDef(name = "deletedFilter", parameters = [ParamDef(name = "isDeleted", type = Boolean::class)])
|
|
114
|
+
@Filter(name = "deletedFilter", condition = "deleted_at IS NULL")
|
|
115
|
+
class User(
|
|
116
|
+
@Id @GeneratedValue(strategy = GenerationType.UUID)
|
|
117
|
+
val id: UUID = UUID.randomUUID(),
|
|
118
|
+
|
|
119
|
+
@Column(name = "email", nullable = false, unique = true)
|
|
120
|
+
var email: String,
|
|
121
|
+
|
|
122
|
+
@Column(name = "deleted_at")
|
|
123
|
+
var deletedAt: Instant? = null
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Alternative using `@Where` (simpler, but applies globally without ability to
|
|
128
|
+
disable):
|
|
129
|
+
|
|
130
|
+
```kotlin
|
|
131
|
+
@Entity
|
|
132
|
+
@Table(name = "users")
|
|
133
|
+
@SQLDelete(sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?")
|
|
134
|
+
@Where(clause = "deleted_at IS NULL")
|
|
135
|
+
class User(...)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Column Annotations
|
|
139
|
+
|
|
140
|
+
Always be explicit. Never rely on Hibernate defaults.
|
|
141
|
+
|
|
142
|
+
```kotlin
|
|
143
|
+
@Column(
|
|
144
|
+
name = "email", // explicit column name
|
|
145
|
+
nullable = false, // maps to NOT NULL constraint
|
|
146
|
+
unique = true, // maps to UNIQUE constraint
|
|
147
|
+
length = 255 // VARCHAR(255) — ignored for TEXT type
|
|
148
|
+
)
|
|
149
|
+
var email: String
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
For PostgreSQL `TEXT` type (unbounded), use `columnDefinition`:
|
|
153
|
+
|
|
154
|
+
```kotlin
|
|
155
|
+
@Column(name = "description", nullable = false, columnDefinition = "TEXT")
|
|
156
|
+
var description: String
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Indexes
|
|
160
|
+
|
|
161
|
+
Define indexes on the entity, not in migration scripts, for searchable fields.
|
|
162
|
+
|
|
163
|
+
```kotlin
|
|
164
|
+
@Entity
|
|
165
|
+
@Table(
|
|
166
|
+
name = "users",
|
|
167
|
+
indexes = [
|
|
168
|
+
Index(name = "idx_users_email", columnList = "email", unique = true),
|
|
169
|
+
Index(name = "idx_users_created_at", columnList = "created_at")
|
|
170
|
+
]
|
|
171
|
+
)
|
|
172
|
+
class User(...)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
For partial indexes (e.g., soft-delete), use a migration script — JPA cannot
|
|
176
|
+
express partial indexes.
|
|
177
|
+
|
|
178
|
+
## Relationship Mapping
|
|
179
|
+
|
|
180
|
+
### One-to-Many
|
|
181
|
+
|
|
182
|
+
```kotlin
|
|
183
|
+
@Entity
|
|
184
|
+
@Table(name = "orders")
|
|
185
|
+
class Order(
|
|
186
|
+
@Id @GeneratedValue(strategy = GenerationType.UUID)
|
|
187
|
+
val id: UUID = UUID.randomUUID(),
|
|
188
|
+
|
|
189
|
+
@OneToMany(
|
|
190
|
+
mappedBy = "order",
|
|
191
|
+
cascade = [CascadeType.ALL],
|
|
192
|
+
fetch = FetchType.LAZY, // ALWAYS lazy by default
|
|
193
|
+
orphanRemoval = true
|
|
194
|
+
)
|
|
195
|
+
val items: MutableList<OrderItem> = mutableListOf()
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
@Entity
|
|
199
|
+
@Table(name = "order_items")
|
|
200
|
+
class OrderItem(
|
|
201
|
+
@Id @GeneratedValue(strategy = GenerationType.UUID)
|
|
202
|
+
val id: UUID = UUID.randomUUID(),
|
|
203
|
+
|
|
204
|
+
@ManyToOne(fetch = FetchType.LAZY)
|
|
205
|
+
@JoinColumn(name = "order_id", nullable = false)
|
|
206
|
+
val order: Order,
|
|
207
|
+
|
|
208
|
+
@Column(name = "quantity", nullable = false)
|
|
209
|
+
var quantity: Int
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Rules:
|
|
214
|
+
|
|
215
|
+
- `fetch = FetchType.LAZY` is the default for `@OneToMany` — make it explicit
|
|
216
|
+
anyway
|
|
217
|
+
- `CascadeType.ALL` only when the child lifecycle is completely owned by the
|
|
218
|
+
parent
|
|
219
|
+
- `orphanRemoval = true` when removing an item from the collection should delete
|
|
220
|
+
the row
|
|
221
|
+
|
|
222
|
+
### Many-to-One
|
|
223
|
+
|
|
224
|
+
```kotlin
|
|
225
|
+
@ManyToOne(fetch = FetchType.LAZY) // LAZY — never EAGER
|
|
226
|
+
@JoinColumn(name = "user_id", nullable = false)
|
|
227
|
+
val user: User
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`FetchType.EAGER` on `@ManyToOne` causes N+1 problems. Always use `LAZY` and
|
|
231
|
+
fetch eagerly with `JOIN FETCH` when needed.
|
|
232
|
+
|
|
233
|
+
### Many-to-Many
|
|
234
|
+
|
|
235
|
+
Do not use `@ManyToMany` directly. Use a join entity with explicit fields.
|
|
236
|
+
|
|
237
|
+
```kotlin
|
|
238
|
+
// bad — opaque join table, no room for additional fields
|
|
239
|
+
@ManyToMany
|
|
240
|
+
@JoinTable(name = "user_roles")
|
|
241
|
+
val roles: MutableSet<Role>
|
|
242
|
+
|
|
243
|
+
// good — explicit join entity
|
|
244
|
+
@Entity
|
|
245
|
+
@Table(name = "user_roles")
|
|
246
|
+
class UserRole(
|
|
247
|
+
@Id @GeneratedValue(strategy = GenerationType.UUID)
|
|
248
|
+
val id: UUID = UUID.randomUUID(),
|
|
249
|
+
|
|
250
|
+
@ManyToOne(fetch = FetchType.LAZY)
|
|
251
|
+
@JoinColumn(name = "user_id", nullable = false)
|
|
252
|
+
val user: User,
|
|
253
|
+
|
|
254
|
+
@ManyToOne(fetch = FetchType.LAZY)
|
|
255
|
+
@JoinColumn(name = "role_id", nullable = false)
|
|
256
|
+
val role: Role,
|
|
257
|
+
|
|
258
|
+
@Column(name = "assigned_at", nullable = false)
|
|
259
|
+
val assignedAt: Instant = Instant.now()
|
|
260
|
+
)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The join entity approach is more flexible — you can add fields like
|
|
264
|
+
`assignedAt`, `assignedBy`, and query through a repository.
|
|
265
|
+
|
|
266
|
+
## N+1 Problem
|
|
267
|
+
|
|
268
|
+
The N+1 problem occurs when loading a collection results in one query for the
|
|
269
|
+
parent and N additional queries for each child, where N is the number of
|
|
270
|
+
parents.
|
|
271
|
+
|
|
272
|
+
**Detection:**
|
|
273
|
+
|
|
274
|
+
Enable SQL logging in tests:
|
|
275
|
+
|
|
276
|
+
```yaml
|
|
277
|
+
# application-test.yml
|
|
278
|
+
spring:
|
|
279
|
+
jpa:
|
|
280
|
+
show-sql: true
|
|
281
|
+
properties:
|
|
282
|
+
hibernate:
|
|
283
|
+
format_sql: true
|
|
284
|
+
logging:
|
|
285
|
+
level:
|
|
286
|
+
org.hibernate.SQL: DEBUG
|
|
287
|
+
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Count the queries. If you see `SELECT * FROM order_items WHERE order_id = ?`
|
|
291
|
+
executed once per order, you have N+1.
|
|
292
|
+
|
|
293
|
+
**Fix with `@EntityGraph`:**
|
|
294
|
+
|
|
295
|
+
```kotlin
|
|
296
|
+
@Repository
|
|
297
|
+
interface OrderRepository : JpaRepository<Order, UUID> {
|
|
298
|
+
|
|
299
|
+
@EntityGraph(attributePaths = ["items", "items.product"])
|
|
300
|
+
fun findWithItemsById(id: UUID): Optional<Order>
|
|
301
|
+
|
|
302
|
+
@EntityGraph(attributePaths = ["items"])
|
|
303
|
+
fun findAllWithItems(): List<Order>
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Fix with JPQL JOIN FETCH:**
|
|
308
|
+
|
|
309
|
+
```kotlin
|
|
310
|
+
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
|
|
311
|
+
fun findByIdWithItems(@Param("id") id: UUID): Optional<Order>
|
|
312
|
+
|
|
313
|
+
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
|
|
314
|
+
fun findByStatusWithItems(@Param("status") status: OrderStatus): List<Order>
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Use `DISTINCT` with `JOIN FETCH` on collections to avoid duplicate parent rows
|
|
318
|
+
in the result.
|
|
319
|
+
|
|
320
|
+
**Rule:** never access a lazy collection outside a transaction. This causes
|
|
321
|
+
`LazyInitializationException`. If a service method needs related data, fetch it
|
|
322
|
+
within the same transaction using `@EntityGraph` or `JOIN FETCH`.
|
|
323
|
+
|
|
324
|
+
## Projections for Read Queries
|
|
325
|
+
|
|
326
|
+
When you only need a subset of fields, do not load the full entity. Use
|
|
327
|
+
projections.
|
|
328
|
+
|
|
329
|
+
**Interface projection:**
|
|
330
|
+
|
|
331
|
+
```kotlin
|
|
332
|
+
interface UserSummary {
|
|
333
|
+
val id: UUID
|
|
334
|
+
val email: String
|
|
335
|
+
val name: String
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
@Repository
|
|
339
|
+
interface UserRepository : JpaRepository<User, UUID> {
|
|
340
|
+
fun findAllProjectedBy(): List<UserSummary>
|
|
341
|
+
fun findProjectedById(id: UUID): UserSummary?
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**DTO projection with constructor expression:**
|
|
346
|
+
|
|
347
|
+
```kotlin
|
|
348
|
+
data class UserDto(val id: UUID, val email: String)
|
|
349
|
+
|
|
350
|
+
@Query("SELECT new com.example.user.dto.UserDto(u.id, u.email) FROM User u WHERE u.active = true")
|
|
351
|
+
fun findActiveUserDtos(): List<UserDto>
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Use projections for list endpoints and reports. Load full entities only when you
|
|
355
|
+
need to modify them.
|
|
356
|
+
|
|
357
|
+
## Flyway Migrations
|
|
358
|
+
|
|
359
|
+
### Naming Convention
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
V{timestamp}__{description}.sql
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Use a timestamp, not a sequential number, to avoid conflicts in parallel
|
|
366
|
+
branches.
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
V20260507120000__create_users_table.sql
|
|
370
|
+
V20260507120001__add_users_email_index.sql
|
|
371
|
+
V20260508090000__add_orders_table.sql
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Migration Rules
|
|
375
|
+
|
|
376
|
+
- Never modify a migration that has already been applied in any environment
|
|
377
|
+
- Each migration must be forward-only — Flyway does not support automatic
|
|
378
|
+
rollbacks
|
|
379
|
+
- DDL changes and data migrations must be in separate files
|
|
380
|
+
- Destructive operations (DROP COLUMN, DROP TABLE) require a multi-step process:
|
|
381
|
+
1. Migration 1: deploy code that no longer uses the column
|
|
382
|
+
2. Migration 2: drop the column (safe once all instances are updated)
|
|
383
|
+
|
|
384
|
+
### Schema Migration Example
|
|
385
|
+
|
|
386
|
+
```sql
|
|
387
|
+
-- V20260507120000__create_users_table.sql
|
|
388
|
+
CREATE TABLE users (
|
|
389
|
+
id UUID NOT NULL PRIMARY KEY,
|
|
390
|
+
email VARCHAR(255) NOT NULL,
|
|
391
|
+
name VARCHAR(100) NOT NULL,
|
|
392
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
393
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
394
|
+
deleted_at TIMESTAMPTZ
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE deleted_at IS NULL;
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Rollback Strategy
|
|
401
|
+
|
|
402
|
+
Document rollback SQL in comments at the top of each migration file:
|
|
403
|
+
|
|
404
|
+
```sql
|
|
405
|
+
-- V20260507120001__add_user_role_column.sql
|
|
406
|
+
-- Rollback: ALTER TABLE users DROP COLUMN role;
|
|
407
|
+
|
|
408
|
+
ALTER TABLE users ADD COLUMN role VARCHAR(50) NOT NULL DEFAULT 'USER';
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Automated rollback is not used. Manual rollback is applied only in emergencies.
|
|
412
|
+
|
|
413
|
+
## PostgreSQL Specifics
|
|
414
|
+
|
|
415
|
+
### Column Types
|
|
416
|
+
|
|
417
|
+
| Use case | PostgreSQL type | JPA mapping |
|
|
418
|
+
| ------------------------------- | --------------- | ------------------------------------- |
|
|
419
|
+
| Short text, known max length | `VARCHAR(n)` | `@Column(length = n)` |
|
|
420
|
+
| Long text, no known max | `TEXT` | `@Column(columnDefinition = "TEXT")` |
|
|
421
|
+
| Structured semi-structured data | `JSONB` | `@Column(columnDefinition = "JSONB")` |
|
|
422
|
+
| Timestamps with timezone | `TIMESTAMPTZ` | `Instant` |
|
|
423
|
+
| UUIDs | `UUID` | `UUID` |
|
|
424
|
+
| Money/currency | `NUMERIC(19,4)` | `BigDecimal` |
|
|
425
|
+
|
|
426
|
+
Use `TIMESTAMPTZ` (with timezone), not `TIMESTAMP`. Store all timestamps in UTC.
|
|
427
|
+
|
|
428
|
+
### JSONB
|
|
429
|
+
|
|
430
|
+
Use `JSONB` for semi-structured data that does not warrant its own table.
|
|
431
|
+
|
|
432
|
+
```kotlin
|
|
433
|
+
@Column(name = "metadata", columnDefinition = "JSONB")
|
|
434
|
+
@Type(JsonType::class) // requires hypersistence-utils dependency
|
|
435
|
+
var metadata: Map<String, Any> = emptyMap()
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
JSONB is indexable. JSON is not. Always use JSONB.
|
|
439
|
+
|
|
440
|
+
### Partial Indexes
|
|
441
|
+
|
|
442
|
+
Partial indexes cannot be expressed through JPA annotations. Use a migration:
|
|
443
|
+
|
|
444
|
+
```sql
|
|
445
|
+
-- For soft-deleted tables: only index active (non-deleted) rows
|
|
446
|
+
CREATE UNIQUE INDEX idx_users_email_active ON users (email) WHERE deleted_at IS NULL;
|
|
447
|
+
|
|
448
|
+
-- For status-filtered queries
|
|
449
|
+
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'PENDING';
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Native Queries with RETURNING
|
|
453
|
+
|
|
454
|
+
Use `RETURNING` to avoid an extra `SELECT` after an `INSERT` or `UPDATE`:
|
|
455
|
+
|
|
456
|
+
```kotlin
|
|
457
|
+
@Modifying
|
|
458
|
+
@Query(
|
|
459
|
+
value = "INSERT INTO audit_log (user_id, action, created_at) VALUES (:userId, :action, NOW()) RETURNING id",
|
|
460
|
+
nativeQuery = true
|
|
461
|
+
)
|
|
462
|
+
fun insertAndReturnId(
|
|
463
|
+
@Param("userId") userId: UUID,
|
|
464
|
+
@Param("action") action: String
|
|
465
|
+
): UUID
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### Complex Queries
|
|
469
|
+
|
|
470
|
+
Use native queries when JPQL cannot express what you need:
|
|
471
|
+
|
|
472
|
+
```kotlin
|
|
473
|
+
@Query(
|
|
474
|
+
value = """
|
|
475
|
+
SELECT u.id, u.email, COUNT(o.id) as order_count
|
|
476
|
+
FROM users u
|
|
477
|
+
LEFT JOIN orders o ON o.user_id = u.id AND o.deleted_at IS NULL
|
|
478
|
+
WHERE u.deleted_at IS NULL
|
|
479
|
+
GROUP BY u.id, u.email
|
|
480
|
+
HAVING COUNT(o.id) > :minOrders
|
|
481
|
+
ORDER BY order_count DESC
|
|
482
|
+
LIMIT :limit
|
|
483
|
+
""",
|
|
484
|
+
nativeQuery = true
|
|
485
|
+
)
|
|
486
|
+
fun findActiveUsersWithMinOrders(
|
|
487
|
+
@Param("minOrders") minOrders: Int,
|
|
488
|
+
@Param("limit") limit: Int
|
|
489
|
+
): List<Map<String, Any>>
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
## Query Performance
|
|
493
|
+
|
|
494
|
+
### Pagination
|
|
495
|
+
|
|
496
|
+
Always paginate. Never call `findAll()` on a large table.
|
|
497
|
+
|
|
498
|
+
```kotlin
|
|
499
|
+
@Repository
|
|
500
|
+
interface UserRepository : JpaRepository<User, UUID> {
|
|
501
|
+
fun findAll(pageable: Pageable): Page<UserSummary>
|
|
502
|
+
fun findByRole(role: UserRole, pageable: Pageable): Page<UserSummary>
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// In service
|
|
506
|
+
fun list(page: Int, size: Int): Page<UserSummary> {
|
|
507
|
+
val pageable = PageRequest.of(page, size, Sort.by("createdAt").descending())
|
|
508
|
+
return userRepository.findAll(pageable)
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Use `Slice<T>` instead of `Page<T>` when you do not need the total count
|
|
513
|
+
(cheaper — no COUNT query).
|
|
514
|
+
|
|
515
|
+
### QueryHints for Fetch Control
|
|
516
|
+
|
|
517
|
+
```kotlin
|
|
518
|
+
@QueryHints(
|
|
519
|
+
QueryHint(name = HINT_FETCHGRAPH, value = "User.withOrders")
|
|
520
|
+
)
|
|
521
|
+
fun findById(id: UUID): Optional<User>
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Requires a named entity graph:
|
|
525
|
+
|
|
526
|
+
```kotlin
|
|
527
|
+
@Entity
|
|
528
|
+
@NamedEntityGraph(
|
|
529
|
+
name = "User.withOrders",
|
|
530
|
+
attributeNodes = [NamedAttributeNode("orders")]
|
|
531
|
+
)
|
|
532
|
+
class User(...)
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
## Testing
|
|
536
|
+
|
|
537
|
+
### Unit — @DataJpaTest with H2
|
|
538
|
+
|
|
539
|
+
Fast. No server required.
|
|
540
|
+
|
|
541
|
+
```kotlin
|
|
542
|
+
@DataJpaTest
|
|
543
|
+
class UserRepositoryTest {
|
|
544
|
+
|
|
545
|
+
@Autowired
|
|
546
|
+
private lateinit var userRepository: UserRepository
|
|
547
|
+
|
|
548
|
+
@Test
|
|
549
|
+
fun `should find user by email`() {
|
|
550
|
+
val saved = userRepository.save(User(email = "test@example.com", name = "Test User"))
|
|
551
|
+
val found = userRepository.findByEmail("test@example.com")
|
|
552
|
+
assertThat(found).isNotNull
|
|
553
|
+
assertThat(found?.id).isEqualTo(saved.id)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
@Test
|
|
557
|
+
fun `should not find deleted user by email`() {
|
|
558
|
+
val user = userRepository.save(User(email = "deleted@example.com", name = "Gone"))
|
|
559
|
+
userRepository.delete(user) // triggers @SQLDelete — sets deleted_at
|
|
560
|
+
val found = userRepository.findByEmail("deleted@example.com")
|
|
561
|
+
assertThat(found).isNull()
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
### Integration — Testcontainers with PostgreSQL
|
|
567
|
+
|
|
568
|
+
Real database. Use for migrations, JSONB, partial indexes, and native queries.
|
|
569
|
+
|
|
570
|
+
```kotlin
|
|
571
|
+
@SpringBootTest
|
|
572
|
+
@Testcontainers
|
|
573
|
+
class UserRepositoryIntegrationTest {
|
|
574
|
+
|
|
575
|
+
companion object {
|
|
576
|
+
@Container
|
|
577
|
+
@JvmStatic
|
|
578
|
+
val postgres = PostgreSQLContainer<Nothing>("postgres:16").apply {
|
|
579
|
+
withDatabaseName("testdb")
|
|
580
|
+
withUsername("test")
|
|
581
|
+
withPassword("test")
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
@DynamicPropertySource
|
|
585
|
+
@JvmStatic
|
|
586
|
+
fun registerProperties(registry: DynamicPropertyRegistry) {
|
|
587
|
+
registry.add("spring.datasource.url", postgres::getJdbcUrl)
|
|
588
|
+
registry.add("spring.datasource.username", postgres::getUsername)
|
|
589
|
+
registry.add("spring.datasource.password", postgres::getPassword)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
@Autowired
|
|
594
|
+
private lateinit var userRepository: UserRepository
|
|
595
|
+
|
|
596
|
+
@Test
|
|
597
|
+
fun `should enforce unique email constraint at db level`() {
|
|
598
|
+
userRepository.save(User(email = "dup@example.com", name = "First"))
|
|
599
|
+
assertThrows<DataIntegrityViolationException> {
|
|
600
|
+
userRepository.save(User(email = "dup@example.com", name = "Second"))
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Use Testcontainers for:
|
|
607
|
+
|
|
608
|
+
- Flyway migration validation
|
|
609
|
+
- PostgreSQL-specific features (JSONB, partial indexes, native queries)
|
|
610
|
+
- Constraint enforcement (unique, foreign key, check constraints)
|
|
611
|
+
- Query performance checks that differ between H2 and PostgreSQL
|
|
612
|
+
|
|
613
|
+
Use `@DataJpaTest` with H2 for:
|
|
614
|
+
|
|
615
|
+
- Derived query methods
|
|
616
|
+
- Simple CRUD operations
|
|
617
|
+
- JPQL queries with standard JPA behavior
|