Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/superpowers/plans/2026-03-30-join-on-scoped-dsl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Join On Scoped DSL Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add optional scoped DSL conditions for `join on` while preserving existing annotation-driven join behavior.

**Architecture:** Introduce a small join-on annotation and a runtime query API that both flow into `JoinField`. Render extra `on` predicates with a dedicated scoped SQL builder so existing `where` parsing and SQL generation remain stable.

**Tech Stack:** Java, JUnit 5, Gradle

---

## Chunk 1: API and failing coverage

### Task 1: Add design-facing tests for annotation and runtime join-on

**Files:**
- Modify: `src/test/java/cn/beagile/dslquery/DeepJoinTest.java`
- Modify: `src/test/java/cn/beagile/dslquery/DSLQueryTest.java`

- [ ] **Step 1: Write the failing tests**
- [ ] **Step 2: Run the focused test command and confirm the new tests fail for the expected reason**
- [ ] **Step 3: Keep existing snapshots unchanged**

### Task 2: Add scoped resolution coverage

**Files:**
- Create: `src/test/java/cn/beagile/dslquery/JoinOnSQLBuilderTest.java`

- [ ] **Step 1: Write failing tests for `self`, `parent`, `root`, and unknown-field handling**
- [ ] **Step 2: Run the focused test command and confirm the failures are correct**

## Chunk 2: Minimal implementation

### Task 3: Add the new annotation and runtime API

**Files:**
- Create: `src/main/java/cn/beagile/dslquery/JoinOn.java`
- Modify: `src/main/java/cn/beagile/dslquery/DSLQuery.java`

- [ ] **Step 1: Add `@JoinOn` with runtime retention on fields**
- [ ] **Step 2: Add `DSLQuery.joinOn(path, dsl)` and storage for runtime join-on conditions**
- [ ] **Step 3: Run focused tests**

### Task 4: Render join-on DSL in `JoinField`

**Files:**
- Modify: `src/main/java/cn/beagile/dslquery/ColumnFields.java`
- Modify: `src/main/java/cn/beagile/dslquery/JoinField.java`
- Create: `src/main/java/cn/beagile/dslquery/JoinOnSQLBuilder.java`
- Modify: `src/main/java/cn/beagile/dslquery/SQLBuilder.java`
- Modify: `src/main/java/cn/beagile/dslquery/SingleExpression.java`

- [ ] **Step 1: Thread merged join-on strings into each `JoinField`**
- [ ] **Step 2: Build scoped field lookup for `self`, `parent`, and `root`**
- [ ] **Step 3: Support `@fieldRef` values only when the builder opts in**
- [ ] **Step 4: Run focused tests and make them pass**

## Chunk 3: Verification

### Task 5: Regression verification

**Files:**
- Modify: `src/test/java/cn/beagile/dslquery/ColumnFieldsTest.java`

- [ ] **Step 1: Add a regression test proving joins are unchanged without join-on**
- [ ] **Step 2: Run focused join-related test suites**
- [ ] **Step 3: Run a broader repository test command if practical**
97 changes: 97 additions & 0 deletions docs/superpowers/specs/2026-03-30-join-on-scoped-dsl-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Join On Scoped DSL Design

**Goal:** Add extra `join on` conditions without breaking the existing annotation model or string DSL style.

## Summary

The current join path always renders a fixed equality predicate from `@JoinColumn` or `@JoinColumns`. This design adds optional scoped DSL conditions that are appended to `on`, while keeping existing queries unchanged.

The new capability has two entry points:

- Field annotation: `@JoinOn("(and(enabled eq true))")`
- Runtime API: `DSLQuery.joinOn("org", "(and(type eq SALES))")`

Both feed the same internal model and are merged with `and`.

## DSL Semantics

The feature reuses the existing DSL syntax:

```java
(and(enabled eq true)(tenantId eq @parent.tenantId))
```

Field resolution is scoped to the current join:

- Bare field name: current join target (`self`)
- `self.xxx`: current join target
- `parent.xxx`: immediate owner of the join
- `root.xxx`: root query object

Values that start with `@` are treated as field references instead of bound parameters:

- `@parent.tenantId`
- `@root.companyId`
- `@self.code`

This is only enabled for join-on rendering. Normal `where` behavior stays unchanged.

## Compatibility Rules

- No `@JoinOn` and no `DSLQuery.joinOn(...)` means generated SQL stays byte-for-byte compatible with the current implementation.
- Existing `where`, `sort`, `deepJoinIncludes`, and `selectIgnores` behavior remains unchanged.
- The new syntax does not modify `@JoinColumn` or `@JoinColumns`.

## SQL Rendering

Base join equality remains first, then extra predicates are appended with `and`.

```sql
left join t_org org_
on org_.id = t_user.org_id
and org_.enabled = :j0_p0
and org_.tenant_id = t_user.tenant_id
```

## Boundaries

- Extra `on` predicates can reference mapped `@Column` fields from `self`, `parent`, or `root`.
- They do not reference raw join-key column names unless those columns are also modeled as `@Column` fields.
- For `@JoinColumns`, v1 appends scoped predicates to the final target-table join, not to intermediate bridge joins.
- Unknown scoped fields fail fast instead of silently rendering `true`.

## Internal Design

### New API surface

- Add `@JoinOn`
- Add `DSLQuery.joinOn(String path, String dsl)`

### Builder flow

```mermaid
flowchart LR
A["@JoinOn"] --> D["ColumnFields"]
B["DSLQuery.joinOn(path, dsl)"] --> D
D --> E["JoinField"]
E --> F["JoinOnSQLBuilder"]
F --> G["join SQL fragment"]
F --> H["shared params map"]
```

### Main implementation pieces

- `DSLQuery`: store runtime join-on DSL strings by join path
- `ColumnFields`: pass merged join-on definitions into each `JoinField`
- `JoinField`: render base equality plus extra scoped predicates
- `JoinOnSQLBuilder`: resolve scoped field names and bind params into the main query param map
- `SingleExpression`: support builder-opted field-reference values for join-on rendering only

## Test Strategy

- Annotation-driven join-on adds constant predicate to `on`
- Runtime `joinOn(path, dsl)` adds predicate to `on`
- Annotation and runtime conditions merge with `and`
- `@parent.xxx` renders a column-to-column comparison
- Unknown scoped field throws
- Existing join SQL snapshots remain unchanged when no join-on is configured
70 changes: 70 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- **类型安全**:基于JPA注解的强类型映射
- **自动SQL生成**:自动将DSL转换为优化的SQL查询
- **深度关联查询**:支持多级JOIN和OneToMany关系
- **Join On扩展**:支持在关联ON子句中追加DSL条件
- **灵活配置**:支持字段忽略、深度关联控制、时区转换
- **分页支持**:内置分页查询功能
- **数据库无关**:通过QueryExecutor接口适配不同数据库
Expand Down Expand Up @@ -250,6 +251,22 @@ private Contact contact;
private Org org;
```

#### @JoinOn
为关联的 `join on` 子句追加DSL条件

```java
@JoinColumn(name = "org_id", referencedColumnName = "id")
@JoinOn("(and(enabled eq true)(tenantId eq @parent.tenantId))")
private Org org;
```

支持以下作用域:

- 裸字段名或 `self.xxx`:当前join目标对象
- `parent.xxx`:当前join的上一级对象
- `root.xxx`:根查询对象
- `@fieldPath`:把值解释为字段引用,而不是绑定参数

#### @OneToMany
一对多关系(JPA标准注解)

Expand Down Expand Up @@ -366,6 +383,59 @@ List<Person> result = new DSLQuery<>(executor, Person.class)
// where area.name = 'Beijing'
```

### 关联Join On扩展条件

```java
@View("person")
public class Person {
@Column(name = "tenant_id")
private Long tenantId;

@JoinColumn(name = "org_id", referencedColumnName = "id")
@JoinOn("(and(enabled eq true)(tenantId eq @parent.tenantId))")
private Org org;
}

@View("org")
public class Org {
@Column(name = "tenant_id")
private Long tenantId;

@Column(name = "enabled")
private Boolean enabled;

@Column(name = "type")
private String type;
}

// 注解条件 + 运行时条件一起追加到ON
List<Person> result = new DSLQuery<>(executor, Person.class)
.joinOn("org", "(and(type eq SALES))")
.query();

// 生成的SQL类似:
// select person.tenant_id, org.tenant_id, org.enabled, org.type
// from person
// left join org on org.id = person.org_id
// and org.enabled = true
// and org.tenant_id = person.tenant_id
// and org.type = 'SALES'
```

深层关联同样支持作用域字段引用:

```java
@View("org")
public class Org {
@Column(name = "tenant_id")
private Long tenantId;

@JoinColumn(name = "area_id", referencedColumnName = "id")
@JoinOn("(and(code eq @root.areaCode)(tenantId eq @parent.tenantId))")
private Area area;
}
```

### OneToMany关系

```java
Expand Down
25 changes: 23 additions & 2 deletions src/main/java/cn/beagile/dslquery/ColumnFields.java
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,29 @@ private void readJoinFields(Class clz, List<Field> parents, Class<? extends Anno

private void readJoinFieldsFromField(List<Field> parents, Field field) {
List<Field> newParents = newParents(parents, field);
joinFields.add(new JoinField(field, newParents));
joinFields.add(new JoinField(field, newParents, joinOnConditions(field, newParents), joinFields.size()));
readJoinColumnFields(field, newParents);
readEmbeddedFields(field.getType(), newParents);
readJoins(field.getType(), newParents);
}

private List<String> joinOnConditions(Field field, List<Field> parents) {
List<String> result = new ArrayList<>();
if (field.isAnnotationPresent(JoinOn.class)) {
result.addAll(Arrays.asList(field.getAnnotation(JoinOn.class).value()));
}
@SuppressWarnings("unchecked")
List<String> outerJoinOns = (List<String>) dslQuery.getJoinOns().get(pathOf(parents));
if (outerJoinOns != null) {
result.addAll(outerJoinOns);
}
return result;
}

private String pathOf(List<Field> parents) {
return parents.stream().map(Field::getName).collect(Collectors.joining("."));
}

private void readJoinColumnFields(Field field, List<Field> newParents) {
Arrays.stream(field.getType().getDeclaredFields())
.filter(f -> f.isAnnotationPresent(Column.class))
Expand Down Expand Up @@ -183,7 +200,11 @@ public List<String> joined() {
}

public String joins() {
return joinFields.stream().map(JoinField::joinStatement).collect(Collectors.joining("\n"));
return joins(new HashMap<>(), 0);
}

public String joins(Map<String, Object> params, int timezoneOffset) {
return joinFields.stream().map(joinField -> joinField.joinStatement(params, timezoneOffset)).collect(Collectors.joining("\n"));
}

public boolean hasField(Field field, List<Field> parents) {
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/cn/beagile/dslquery/DSLQuery.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class DSLQuery<T> {
private final QueryExecutor queryExecutor;
Expand All @@ -14,6 +16,7 @@ public class DSLQuery<T> {
private int timezoneOffset;
private List<String> deepJoins = new ArrayList<>();
private List<String> selectIgnores = new ArrayList<>();
private Map<String, List<String>> joinOns = new LinkedHashMap<>();
private NullsOrder nullsOrder;
private WhereParser whereParser = new WhereParser();
;
Expand Down Expand Up @@ -124,4 +127,16 @@ public DSLQuery<T> selectIgnores(String... selectIgnores) {
public List<String> getSelectIgnores() {
return selectIgnores;
}

public DSLQuery<T> joinOn(String path, String dsl) {
if (path == null || path.isEmpty() || dsl == null || dsl.isEmpty()) {
return this;
}
joinOns.computeIfAbsent(path, key -> new ArrayList<>()).add(dsl);
return this;
}

public Map<String, List<String>> getJoinOns() {
return joinOns;
}
}
Loading
Loading