Skip to content

Commit a002e76

Browse files
committed
인텔리센스 기능 개선
1 parent f2dd0b7 commit a002e76

4 files changed

Lines changed: 463 additions & 7 deletions

File tree

change.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5816,3 +5816,186 @@ DB docker 이미지는 사용자 요청대로 **항상 한 번에 하나만**
58165816
- `src/ui/sql_editor/format_sweep_tests.rs`: 의미 구문/JSON query-array 깊이 일반 감사,
58175817
내장 회귀와 감사 자체 테스트.
58185818
- `change.md`: 본 AS-IS/TO-BE, 전수 판독, live 실행, 최종 검증 기록.
5819+
5820+
# 41. 자동 포맷 전수 재감사 · Oracle 연산자/JSON 값 프레임 교정 (2026-07-30)
5821+
5822+
## 41-1. AS-IS / TO-BE
5823+
5824+
`cargo test --lib formatting_sweep_all_files_generate_out_report -- --ignored --nocapture`
5825+
첫 실행은 138개 산출물 모두 PASS였지만, 141,471줄을 처음부터 끝까지 직접 판독해
5826+
기존 감사가 놓친 실제 결함 2종을 확인했다.
5827+
5828+
### Oracle `^=` 연산자 보존
5829+
5830+
AS-IS:
5831+
5832+
```sql
5833+
WHERE p.pos_id ^ = 999
5834+
```
5835+
5836+
TO-BE:
5837+
5838+
```sql
5839+
WHERE p.pos_id ^= 999
5840+
```
5841+
5842+
Oracle의 caret not-equal 연산자를 토크나이저가 `^`, `=` 두 symbol로 나누면서
5843+
포매터가 공백을 삽입했다. 최적화/기준 토크나이저가 모두 `^=`를 단일 symbol로
5844+
인식하게 수정했다.
5845+
5846+
source와 formatted를 같은 tokenizer로 지문 처리하는 기존 token fingerprint만으로는
5847+
tokenizer 자체가 두 symbol로 회귀하는 경우를 놓칠 수 있다. 따라서 sweep에 tokenizer의
5848+
compound-symbol 묶음 여부와 독립적인 compact-operator boundary 감사를 추가했다.
5849+
원문에서 붙어 있던
5850+
`<=>`, `<->`, `<#>`, `->>`, `<<`, `>>`, `<=`, `>=`, `<>`, `!=`, `^=`, `||`,
5851+
`**`, `:=`, `=>`, `->`가 출력에서도 불가분 경계를 유지하는지 순서대로 비교한다.
5852+
5853+
### SQL/JSON `VALUE CASE`의 값 소유 깊이
5854+
5855+
AS-IS:
5856+
5857+
```sql
5858+
'flags' VALUE JSON_OBJECT ('remote' VALUE b.remote_flag,
5859+
'active' VALUE
5860+
CASE
5861+
WHEN b.status = 'ACTIVE' THEN 'true'
5862+
ELSE 'false'
5863+
END
5864+
)
5865+
```
5866+
5867+
TO-BE:
5868+
5869+
```sql
5870+
'flags' VALUE JSON_OBJECT ('remote' VALUE b.remote_flag,
5871+
'active' VALUE
5872+
CASE
5873+
WHEN b.status = 'ACTIVE' THEN 'true'
5874+
ELSE 'false'
5875+
END
5876+
)
5877+
```
5878+
5879+
`JSON_OBJECT`/`JSON_OBJECTAGG``VALUE`가 값 표현식을 소유하지만 기존에는
5880+
`AssignmentValue` frame을 열지 않아 `CASE``VALUE` owner가 같은 깊이로
5881+
렌더링됐다. Oracle SQL/JSON 괄호를 semantic flag로 식별하고 `VALUE`에서 활성
5882+
괄호의 자식 깊이를 owner로 하는 `AssignmentValue` frame을 연다. 물리 행의 선행
5883+
공백은 사용하지 않으므로 첫 JSON 항목이 `SELECT` 행에 inline이어도 논리 깊이는
5884+
흔들리지 않는다.
5885+
5886+
값 전체가 괄호식인 경우에는 semantic value frame과 delimiter frame이 같은 문법
5887+
간선을 표현하므로 하나의 깊이를 공유한다. 이 규칙으로 scalar subquery를
5888+
불필요하게 두 단계 더 들여쓰는 첫 재스윕의 단일 감사 실패도 일반적으로 해소했다.
5889+
5890+
검토 중 후보였던 `MERGE ... VALUES (`의 여러 줄 row 닫는 `)`는 수정하지 않았다.
5891+
`VALUES` 목록의 첫 row가 owner 행에 inline이어도 논리적으로는 목록 자식 깊이에
5892+
있고, row 괄호의 닫힘은 자신이 닫는 row owner 깊이로 돌아온다. 이는
5893+
`docs/auto_format_rule.md`의 첫 자식 inline 및 closing-boundary 규칙에 맞는
5894+
의도된 출력이다.
5895+
5896+
## 41-2. 자동 감사와 회귀 고정
5897+
5898+
- `tokenize_sql_treats_oracle_caret_not_equal_as_single_symbol`: 최적화 토크나이저와
5899+
기준 토크나이저가 모두 `^=`를 단일 token으로 보존하는지 확인한다.
5900+
- `format_for_auto_formatting_preserves_oracle_caret_not_equal_operator`: 실제 formatter
5901+
출력, frame 감사, 멱등성에서 `^=` 보존을 고정한다.
5902+
- `format_for_auto_formatting_nests_json_object_value_case_under_value_owner`: `VALUE`,
5903+
`CASE`, `WHEN`/`ELSE`, `END`의 owner→child 깊이와 frame 감사, 멱등성을 고정한다.
5904+
- `formatting_sweep_detects_split_compact_operator_boundary`: tokenizer 자체 회귀가
5905+
있더라도 원문의 `^=``^ =`로 분리되면 compact-operator boundary 감사가
5906+
`ItemOrTokenChanged`를 보고하는지 확인한다.
5907+
- 기존
5908+
`format_for_auto_formatting_keeps_json_object_value_scalar_subquery_on_paren_frame_depth`
5909+
frame 감사 assertion을 추가해 value frame과 괄호 frame의 단일 간선 공유를
5910+
고정한다.
5911+
-`AssignmentValue` frame은 기존 `FormatFrameAlignmentAudit`에 그대로 등록되므로
5912+
parent→child가 정확히 `+1`인지, 첫 body token이 frame 깊이에 있는지, frame이
5913+
유효 scope 밖으로 누수되지 않는지를 전체 sweep에서 자동 검사한다.
5914+
- built-in inline-query 회귀도 `JSON_OBJECT VALUE CASE``VALUE (SELECT ...)`를 한
5915+
문장에 포함하도록 확장해 두 value 형태가 매 exact sweep에서 같은 typed-frame
5916+
감사를 반드시 통과한다. Oracle `^=` built-in 회귀도 함께 추가했다.
5917+
5918+
## 41-3. 생성 결과 전수 판독과 재검토
5919+
5920+
수정 후 생성된 138개 `.format.out`의 141,471줄을 모두 처음부터 끝까지 직접
5921+
판독했다. footer의 PASS 표시는 판정 근거로 사용하지 않고 owner→child 깊이,
5922+
형제 정렬, close 복귀, 토큰 순서, 주석/리터럴 격리를 행 단위로 확인했다.
5923+
5924+
- 임시 detached worktree에서 수정 전 `HEAD` exact sweep을 다시 실행해 baseline
5925+
138개를 재현했다.
5926+
- 현재 산출물과 상대 경로별 SHA-256을 비교한 결과 124개는 byte-identical이고,
5927+
Oracle 7개 fixture × 2 layout의 14개가 변경됐으며 누락 파일은 0개였다.
5928+
- compact-operator/built-in 감사 보강 후 exact sweep을 다시 실행한 최종 138개는
5929+
전수 판독본과 138/138 byte-identical이었다. 전체 manifest SHA-256은
5930+
`2bbde88b6df4a9f1f7408b366db0252f79c4220c849666ea302cf73eb833eb15`다.
5931+
- 실제 `[[FMT:E숫자...]]` marker, `status: FAIL`, 0이 아닌 issue 합계는 모두 0건.
5932+
5933+
최종 산출물:
5934+
5935+
| layout / dialect | 파일 수 | 줄 수 |
5936+
| --- | ---: | ---: |
5937+
| wrapped / Oracle | 43 | 30,851 |
5938+
| wrapped / MySQL | 12 | 12,700 |
5939+
| wrapped / MariaDB | 14 | 14,907 |
5940+
| stacked / Oracle | 43 | 41,548 |
5941+
| stacked / MySQL | 12 | 19,083 |
5942+
| stacked / MariaDB | 14 | 22,382 |
5943+
| 합계 | 138 | 141,471 |
5944+
5945+
최종 exact sweep은 88.71초에 PASS했다.
5946+
5947+
| 감사 항목 | wrapped | stacked |
5948+
| --- | ---: | ---: |
5949+
| checked files | 69 | 69 |
5950+
| checked regressions | 34 | 34 |
5951+
| identifier-case words | 152,339 | 152,339 |
5952+
| frames / frame boundaries | 42,027 / 88,740 | 42,027 / 88,740 |
5953+
| frame-depth symmetries | 4,889 | 5,570 |
5954+
| frame body items | 15,873 | 40,209 |
5955+
| frame closes | 19,682 | 19,682 |
5956+
| failures | 0 | 0 |
5957+
5958+
## 41-4. 포맷 이후 Space Query live 실행
5959+
5960+
DB 컨테이너는 Oracle → 중지 → MySQL → 중지 → MariaDB → 중지 순으로 실행해
5961+
항상 하나만 실행했다. 최종 확인 시 모든 DB 컨테이너가 정지 상태였다.
5962+
5963+
각 fixture 원문을 Wrapped와 Stacked로 포맷한 후 dialect별 SPACE Query production
5964+
배치 경로로 실제 DB에 실행했다. 모든 파일이 성공 statement를 하나 이상 냈고
5965+
실패 statement/event는 없었다.
5966+
5967+
| DB | fixture × layout | successful statements | 결과 |
5968+
| --- | ---: | ---: | --- |
5969+
| Oracle Free | 43 × 2 | 6,994 | PASS |
5970+
| MySQL 8.0.46 | 12 × 2 | 2,532 | PASS |
5971+
| MariaDB 12.2.2 | 14 × 2 | 3,086 | PASS |
5972+
| 합계 | 69개 원본 / 138회 포맷 실행 | 12,612 | 실패 0 |
5973+
5974+
사용한 ignored live harness:
5975+
5976+
- `oracle_thin_query_tool_runs_all_formatted_fixture_scripts_without_errors`
5977+
- `execute_all_formatted_mysql_fixture_scripts_without_errors`
5978+
- `execute_all_formatted_mariadb_fixture_scripts_without_errors`
5979+
5980+
## 41-5. 최종 품질 게이트
5981+
5982+
| 검증 | 결과 |
5983+
| --- | --- |
5984+
| `cargo test --lib formatting_sweep_all_files_generate_out_report -- --ignored --nocapture` | PASS — 88.71초, 138 산출물, 141,471줄, failures 0 |
5985+
| 수정 전 baseline byte 비교 | PASS — 124개 동일, Oracle 14개 변경, 누락 0 |
5986+
| 최종 재생성 byte 비교 | PASS — 전수 판독본과 138/138 동일, manifest SHA-256 `2bbde88b…` |
5987+
| 포맷 후 Space Query live 실행 | PASS — 138회, 12,612 successful statements, 실패 0 |
5988+
| `cargo test` | PASS — 7,107 passed, 0 failed, 208 ignored |
5989+
| `cargo clippy --locked --all-targets -- -D warnings -W clippy::perf -W clippy::complexity` | PASS — 경고 0 |
5990+
| `cargo fmt --all -- --check` / `git diff --check` | PASS |
5991+
5992+
## 41-6. 변경 파일
5993+
5994+
- `src/ui/sql_editor/query_text.rs`: Oracle `^=` compound symbol의 두 토크나이저 경로와
5995+
회귀 테스트.
5996+
- `src/ui/sql_editor/format_sweep_tests.rs`: compound-token grouping 독립
5997+
compact-operator boundary 감사/자체 테스트, Oracle `^=` built-in 회귀,
5998+
JSON `VALUE CASE` frame 회귀 확장.
5999+
- `src/ui/sql_editor/formatter.rs`: SQL/JSON `VALUE``AssignmentValue` frame,
6000+
parenthesized whole-value depth 공유, CASE/스칼라 서브쿼리 회귀 테스트.
6001+
- `change.md`: 본 AS-IS/TO-BE, 전수 판독, live 실행, 최종 검증 기록.

src/ui/sql_editor/format_sweep_tests.rs

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ const FORMAT_SWEEP_STRUCTURAL_REGRESSION_CASES: &[(DatabaseType, &str)] = &[
6868
),
6969
];
7070
const FORMAT_SWEEP_INLINE_QUERY_FRAME_REGRESSION: &str =
71-
"SELECT JSON_OBJECT ('grand' VALUE (SELECT SUM(amount) FROM orders) RETURNING CLOB) AS payload FROM DUAL;";
71+
"SELECT JSON_OBJECT ('flag' VALUE CASE WHEN active = 1 THEN 'Y' ELSE 'N' END, 'grand' VALUE (SELECT SUM(amount) FROM orders) RETURNING CLOB) AS payload FROM DUAL;";
72+
const FORMAT_SWEEP_COMPACT_OPERATOR_REGRESSION: &str = "SELECT 1 FROM DUAL WHERE pos_id ^= 999;";
7273
const FORMAT_SWEEP_INLINE_SELECT_COMMENT_REGRESSION: &str =
7374
"WITH x AS (SELECT /* first child */ a, b FROM t) SELECT a, b FROM x;";
7475
const FORMAT_SWEEP_WITH_FRAME_REGRESSION: &str =
@@ -1408,6 +1409,99 @@ fn format_sweep_audit_token_count(
14081409
})
14091410
}
14101411

1412+
const FORMAT_SWEEP_COMPACT_OPERATORS: &[&str] = &[
1413+
"<=>", "<->", "<#>", "->>", "<<", ">>", "<=", ">=", "<>", "!=", "^=", "||", "**", ":=", "=>",
1414+
"->",
1415+
];
1416+
1417+
fn format_sweep_compact_operator_fingerprint(
1418+
text: &str,
1419+
db_type: DatabaseType,
1420+
) -> Vec<(String, usize)> {
1421+
let document = FormatSweepDocument::new(text, db_type);
1422+
let mut operators = Vec::new();
1423+
let mut idx = 0usize;
1424+
1425+
while idx < document.tokens.len() {
1426+
let first = &document.tokens[idx];
1427+
if !matches!(first.token, SqlToken::Symbol(_)) {
1428+
idx = idx.saturating_add(1);
1429+
continue;
1430+
}
1431+
1432+
let mut candidate = String::new();
1433+
let mut previous_end = first.start;
1434+
let mut longest_match = None;
1435+
for end_idx in idx..document.tokens.len() {
1436+
let span = &document.tokens[end_idx];
1437+
if span.statement_index != first.statement_index
1438+
|| !matches!(span.token, SqlToken::Symbol(_))
1439+
|| (end_idx > idx && span.start != previous_end)
1440+
{
1441+
break;
1442+
}
1443+
let SqlToken::Symbol(symbol) = &span.token else {
1444+
break;
1445+
};
1446+
candidate.push_str(symbol);
1447+
previous_end = span.end;
1448+
if candidate.len() > 3 {
1449+
break;
1450+
}
1451+
if FORMAT_SWEEP_COMPACT_OPERATORS.contains(&candidate.as_str()) {
1452+
longest_match = Some((candidate.clone(), end_idx));
1453+
}
1454+
}
1455+
1456+
if let Some((operator, end_idx)) = longest_match {
1457+
operators.push((operator, first.start));
1458+
idx = end_idx.saturating_add(1);
1459+
} else {
1460+
idx = idx.saturating_add(1);
1461+
}
1462+
}
1463+
1464+
operators
1465+
}
1466+
1467+
fn format_sweep_audit_compact_operator_boundaries(
1468+
source: &str,
1469+
formatted: &str,
1470+
db_type: DatabaseType,
1471+
) -> Option<FormatSweepIssue> {
1472+
let source_operators = format_sweep_compact_operator_fingerprint(source, db_type);
1473+
let formatted_operators = format_sweep_compact_operator_fingerprint(formatted, db_type);
1474+
let source_symbols = source_operators
1475+
.iter()
1476+
.map(|(operator, _)| operator)
1477+
.collect::<Vec<_>>();
1478+
let formatted_symbols = formatted_operators
1479+
.iter()
1480+
.map(|(operator, _)| operator)
1481+
.collect::<Vec<_>>();
1482+
if source_symbols == formatted_symbols {
1483+
return None;
1484+
}
1485+
1486+
let first_mismatch = source_symbols
1487+
.iter()
1488+
.zip(&formatted_symbols)
1489+
.position(|(source, formatted)| source != formatted)
1490+
.unwrap_or_else(|| source_symbols.len().min(formatted_symbols.len()));
1491+
let marker_offset = formatted_operators
1492+
.get(first_mismatch)
1493+
.map_or(0, |(_, offset)| *offset);
1494+
Some(FormatSweepIssue::new(
1495+
FormatSweepIssueKind::ItemOrTokenChanged,
1496+
formatted,
1497+
marker_offset,
1498+
format!(
1499+
"formatting changed a compact SQL operator boundary at operator {}; source={source_symbols:?} formatted={formatted_symbols:?}",
1500+
first_mismatch.saturating_add(1)
1501+
),
1502+
))
1503+
}
1504+
14111505
fn format_sweep_audit_executable_boundaries(
14121506
formatted: &str,
14131507
db_type: DatabaseType,
@@ -1961,6 +2055,10 @@ fn format_sweep_run_with_config(
19612055
if let Some(issue) = format_sweep_audit_token_count(source, &formatted, db_type) {
19622056
issues.push(issue);
19632057
}
2058+
if let Some(issue) = format_sweep_audit_compact_operator_boundaries(source, &formatted, db_type)
2059+
{
2060+
issues.push(issue);
2061+
}
19642062
issues.extend(format_sweep_audit_executable_boundaries(
19652063
&formatted, db_type,
19662064
));
@@ -2974,6 +3072,21 @@ fn formatting_sweep_first_pass_detects_token_loss() {
29743072
assert_eq!(issue.kind, FormatSweepIssueKind::ItemOrTokenChanged);
29753073
}
29763074

3075+
#[test]
3076+
fn formatting_sweep_detects_split_compact_operator_boundary() {
3077+
let source = "SELECT 1 FROM DUAL WHERE pos_id ^= 999;";
3078+
let formatted_with_split = "SELECT 1 FROM DUAL WHERE pos_id ^ = 999;";
3079+
let issue = format_sweep_audit_compact_operator_boundaries(
3080+
source,
3081+
formatted_with_split,
3082+
DatabaseType::Oracle,
3083+
)
3084+
.expect("splitting a compact SQL operator should be reported");
3085+
3086+
assert_eq!(issue.kind, FormatSweepIssueKind::ItemOrTokenChanged);
3087+
assert!(issue.message.contains("^="), "{}", issue.message);
3088+
}
3089+
29773090
#[test]
29783091
fn formatting_sweep_detects_unterminated_mysql_server_command() {
29793092
let formatted = "DESCRIBE t\n\nSELECT 1;";
@@ -4855,6 +4968,10 @@ fn formatting_sweep_all_files_generate_out_report_for_layout(layout: SqlCommaLis
48554968
DatabaseType::Oracle,
48564969
FORMAT_SWEEP_INLINE_SELECT_COMMENT_REGRESSION,
48574970
),
4971+
(
4972+
DatabaseType::Oracle,
4973+
FORMAT_SWEEP_COMPACT_OPERATOR_REGRESSION,
4974+
),
48584975
(DatabaseType::Oracle, FORMAT_SWEEP_QUERY_BRACKET_REGRESSION),
48594976
(DatabaseType::Oracle, FORMAT_SWEEP_WITH_FRAME_REGRESSION),
48604977
(DatabaseType::MySQL, FORMAT_SWEEP_WITH_FRAME_REGRESSION),

0 commit comments

Comments
 (0)