All files runSql.js

46.42% Statements 13/28
50% Branches 4/8
57.14% Functions 4/7
41.66% Lines 10/24

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92                                              1x                                                                                                             29x 9x 9x   9x 1x   8x 5x 5x   11x    
/**
 * 이 파일이 하는 일:
 * 학생이 작성한 SQL을 브라우저 안에서 실행하고, 기대 결과와 비교해 채점한다.
 *
 * 핵심 아이디어 — sql.js (SQLite를 WebAssembly로 컴파일한 것).
 * 학습 포인트: "SQL을 실행한다"고 하면 서버 DB부터 떠올리기 쉽지만, 그건 위험하다.
 * 학생 SQL에 DROP TABLE이 들어 있으면? 서버가 학생마다 DB를 만들어 줘야 하나?
 * sql.js는 진짜 SQLite 엔진을 브라우저 안에서 돌린다. 문제를 열 때마다
 * 메모리 위에 1회용 DB를 새로 만들고, 닫으면 사라진다.
 *   → 무엇을 실행하든 서버에는 아무 일도 일어나지 않는다. DROP도 DELETE도 자유.
 *   → runJs.js(Web Worker)와 같은 철학이다: "남의 코드는 그 사람의 브라우저에서만".
 *
 * JS 채점(runJs.js)과 달리 Web Worker를 쓰지 않는 이유:
 * SELECT는 코드가 아니라 질의라서 무한루프를 만들기가 어렵고(재귀 CTE 정도가 예외),
 * 데이터가 몇 줄 안 돼 실행이 순식간이다. Worker로 감싸는 복잡함이 이득보다 크다.
 * — 같은 문제(격리)라도 위험의 크기에 따라 장치의 무게를 달리한다.
 */
import initSqlJs from 'sql.js';
// 학습 포인트: ?url 은 Vite 문법 — "이 파일을 번들에 포함하고, 그 주소(URL)를 달라".
// WASM은 JS처럼 import할 수 없어서, 주소를 sql.js에 알려 주고 직접 내려받게 한다.
import sqlWasmUrl from 'sql.js/dist/sql-wasm.wasm?url';
 
/** WASM 로딩은 느리다(수백 KB) — 한 번만 하고 계속 재사용한다(캐싱). */
let sqlJsPromise = null;
 
function loadSqlJs() {
  if (!sqlJsPromise) {
    sqlJsPromise = initSqlJs({ locateFile: () => sqlWasmUrl });
  }
  return sqlJsPromise;
}
 
/**
 * 준비 SQL(테이블+데이터)을 깐 1회용 DB에서 학생 쿼리를 실행한다.
 *
 * @param {string} setupSql CREATE TABLE + INSERT (문제가 제공)
 * @param {string} query 학생이 작성한 SQL
 * @returns {Promise<{columns?: string[], rows?: Array[], error?: string}>}
 */
export async function runSql(setupSql, query) {
  const SQL = await loadSqlJs();
  const db = new SQL.Database(); // 메모리 위 1회용 DB — 새 것으로 시작
  try {
    db.run(setupSql);
    // exec는 [{columns, values}] 배열을 돌려준다. 문장이 여러 개면 결과도 여러 개인데,
    // 채점은 "마지막 SELECT의 결과"로 한다 — 학생이 확인용 문장을 앞에 써도 괜찮도록.
    const results = db.exec(query);
    if (results.length === 0) {
      return { error: '결과가 없습니다. SELECT 문을 작성했는지 확인해 보세요.' };
    }
    const last = results[results.length - 1];
    return { columns: last.columns, rows: last.values };
  } catch (e) {
    // SQLite의 에러 메시지는 영어지만 원문 그대로 보여준다 —
    // "near \"FORM\": syntax error" 같은 메시지를 읽어내는 것도 배워야 할 기술이다.
    return { error: 'SQL 오류: ' + e.message };
  } finally {
    db.close(); // WASM 메모리는 가비지 컬렉터가 못 치운다 — 반드시 직접 닫는다.
  }
}
 
/**
 * 실행 결과를 기대 결과와 비교한다.
 *
 * 채점 규칙:
 *  - 값만 비교한다. 컬럼 "이름"은 비교하지 않는다 — SELECT salary AS 연봉 처럼
 *    별칭을 붙여도 정답이다. (컬럼 개수가 다르면 행 내용이 달라져 어차피 틀린다)
 *  - orderMatters가 false면 행 순서는 무시한다. ORDER BY를 요구하는 문제만 true로 둔다.
 *
 * 학습 포인트: 행 순서를 무시하는 비교는 "정렬해서 비교"로 구현한다.
 * 두 결과를 같은 규칙으로 정렬해 놓으면, 내용이 같은지 순서와 무관하게 확인할 수 있다.
 *
 * @param {{columns: string[], rows: Array[]}} actual 학생 쿼리의 실행 결과
 * @param {{expectedRows: Array[], orderMatters: boolean}} expected 문제의 기대 결과
 * @returns {boolean}
 */
export function gradeSql(actual, expected) {
  // 행을 JSON 문자열로 바꿔 비교한다 — runJs.js의 배열 비교와 같은 요령이다.
  const normalize = (rows) => rows.map((row) => JSON.stringify(row));
  const actualRows = normalize(actual.rows);
  const expectedRows = normalize(expected.expectedRows);
 
  if (actualRows.length !== expectedRows.length) {
    return false;
  }
  if (!expected.orderMatters) {
    actualRows.sort();
    expectedRows.sort();
  }
  return actualRows.every((row, i) => row === expectedRows[i]);
}