📌為什麼 SQL 有 800 筆但 C# 只剩 60 筆?Oracle 資料重複、JOIN 與去重邏輯完整解析

一、前言

在企業後端系統中,我們常遇到「資料在資料庫查出來一大堆,但程式處理後剩很少」的情況。這篇文章用淺顯方式拆解一組真實案例:

Oracle SQL 查出約 800 筆資料,但 C# 內存處理後只剩 60 多筆。
您會看到錯誤來源、為何發生、如何用 SQL 與 C# 各自正確處理,以及比較用法與修正策略。

 


二、背景概念(讓非技術人也懂)

  • 資料來源:有兩張表 APS_Z_PLANAPS_Z_LCM,需要根據某個版本(APS_VERSION)把它們「左外連接(LEFT JOIN)」起來。若某些條件符合(如 process 為「後段」、客戶名稱、數量、產品一致),就把對應的 LCM 資料合併進 plan 資料裡。

  • 需求:把合併後的資料格式化(像日期變成 yyyy-MM-dd 字串、數字加千分位、欄位空值補空字串),再給前端用。

  • 疑問:原始 SQL 撈出 800 筆,但 C# 做完邏輯後只剩 60 多筆。哪裡被「吞掉」了?



    三、主要問題拆解

    1. C# 版本筆數變少的最大原因:過度去重(GroupBy 全欄位當 key)

    GetDataByVersionA 裡面最後一段:

    .GroupBy(r => (
       r.DEMAND_DATE,
       r.DEMAND_QTY,
       ... // 幾十個欄位全部列出
       r.DEAL_QTY
    ))
    .Select(g => g.First())
    

    這段的意思是「把所有欄位值完全一樣的列分在一組,只保留每組的第一筆」。
    如果資料裡大量欄位(格式化後的字串、數字)都重複,那整組就只剩一筆,導致 800 筆縮成 60 多筆。

    解法選擇:

    • 如果原始的 800 筆都要保留:直接移除這段 GroupBy,回傳 resultData

    • 如果要去重,只依「真正判斷重複業務關鍵」的欄位(例如 APS_PLAN_NO + SEQ_NO)分組,不要把所有欄位都當 key。

    2. format/轉字串導致「表面相同」加劇去重

    FormatNumbernull0、其他數字都格式化成字串(例如 "0""1,000"),原本可能不同的細節在字串層面變同一樣,更容易被當成「完全一樣」被歸為一組。

    建議

    • 去重的 Key 用原始 numeric/nullable 型別(不格式化),或限制只以最小必要欄位去重。格式化留給輸出層,不當 group key。

    3. JOIN邏輯在 C# 只有 PROCESS_NAME == "後段" 才嘗試配對

    plan.PROCESS_NAME != "後段"matchingLcm 會是 null,產生一大堆只有 plan 本身(lcm 欄位空白)的資料。若這些也被 group key 全部包含,又碰上很多欄位值相同,也會被壓縮去重。

    建議

    • 如果預期行為是「SQL 層做 LEFT JOIN(包含所有 plan)」再統一把結果映射,應該把連接邏輯下放到 SQL,把 後段 的條件寫在 JOIN ON 裡(像範例 SQL),一次拿齊再映射,避免在 C# 裡做條件性配對造成不一致。



      四、SQL 版本對應:只保留「完全重複」一筆的寫法(用於比對)

      如果你要從 SQL 端拿出「把重複資料(欄位值完全一樣)只顯示一筆」來比對 C# 原始全筆結果,可以用分析函式 ROW_NUMBER()

      WITH raw_data AS (
        -- 原始查出來的欄位(格式化 + NVL 處理)
        SELECT
          CASE WHEN p.DEMAND_DATE IS NULL THEN NULL ELSE TO_CHAR(p.DEMAND_DATE, 'YYYY-MM-DD') END AS DEMAND_DATE,
          NVL(TO_CHAR(p.DEMAND_QTY, 'FM999G999G999'), '0') AS DEMAND_QTY,
          NVL(p.SITE,'') AS SITE,
          NVL(p.CUSTOMER_NAME,'') AS CUSTOMER_NAME,
          NVL(p.APPL,'') AS APPL,
          NVL(l.PLANT,'') AS PLANT,
          NVL(p.PRODUCT_ID,'') AS PRODUCT_ID,
          NVL(l.RECIPE,'') AS RECIPE,
          NVL(l.EQ,'') AS EQ,
          CASE WHEN l.INPUT_DATE IS NULL THEN NULL ELSE TO_CHAR(l.INPUT_DATE,'YYYY-MM-DD') END AS INPUT_DATE,
          NVL(TO_CHAR(p.INPUT_QTY,'FM999G999G999'),'0') AS INPUT_QTY,
          NVL(TO_CHAR(p.NORMAL_OUTPUT,'FM999G999G999'),'0') AS NORMAL_OUTPUT,
          p.APS_PLAN_NO,
          NVL(p.SEQ_NO,'') AS SEQ_NO,
          NVL(p.PAIR_FLAG,'') AS PAIR_FLAG,
          NVL(p.WAITING_FLAG,'') AS WAITING_FLAG,
          p.PROCESS_NAME,
          NVL(TO_CHAR(p.APS_PLAN_LIST,'FM999G999G999'),'0') AS APS_PLAN_LIST,
          NVL(TO_CHAR(p.APS_PLAN_SEQ,'FM999G999G999'),'0') AS APS_PLAN_SEQ,
          p.APS_DEAL,
          CASE WHEN p.APS_DEAL_DATE IS NULL THEN NULL ELSE TO_CHAR(p.APS_DEAL_DATE,'YYYY-MM-DD') END AS APS_DEAL_DATE,
          NVL(TO_CHAR(p.APS_DEAL_QTY,'FM999G999G999'),'0') AS APS_DEAL_QTY,
          NVL(TO_CHAR(p.GROUP_ID,'FM999G999G999'),'0') AS GROUP_ID,
          NVL(p.MODEL_VERSION,'') AS MODEL_VERSION,
          NVL(p.ORI_PLAN_WEEK,'') AS ORI_PLAN_WEEK,
          NVL(p.DEMAND_MONTH,'') AS DEMAND_MONTH,
          NVL(p.DEMAND_WEEK,'') AS DEMAND_WEEK,
          NVL(p.SATISFY_WAY,'') AS SATISFY_WAY,
          CASE WHEN p.DEAL_START_DATE IS NULL THEN NULL ELSE TO_CHAR(p.DEAL_START_DATE,'YYYY-MM-DD') END AS DEAL_START_DATE,
          CASE WHEN p.DEAL_END_DATE IS NULL THEN NULL ELSE TO_CHAR(p.DEAL_END_DATE,'YYYY-MM-DD') END AS DEAL_END_DATE,
          NVL(TO_CHAR(p.DEAL_QTY,'FM999G999G999'),'0') AS DEAL_QTY
        FROM APS_Z_PLAN p
        LEFT JOIN APS_Z_LCM l
          ON l.APS_VERSION   = p.APS_VERSION
         AND p.PROCESS_NAME  = '後段'
         AND l.CUSTOMER_NAME = p.CUSTOMER_NAME
         AND NVL(l.DEMAND_QTY,0) = NVL(p.DEMAND_QTY,0)
         AND l.PRODUCT_ID    = p.PRODUCT_ID
        WHERE p.APS_VERSION = :apsVersion
      )
      SELECT *
      FROM (
        SELECT
          raw_data.*,
          ROW_NUMBER() OVER (
            PARTITION BY
              DEMAND_DATE,
              DEMAND_QTY,
              SITE,
              CUSTOMER_NAME,
              APPL,
              PLANT,
              PRODUCT_ID,
              RECIPE,
              EQ,
              INPUT_DATE,
              INPUT_QTY,
              NORMAL_OUTPUT,
              APS_PLAN_NO,
              SEQ_NO,
              PAIR_FLAG,
              WAITING_FLAG,
              PROCESS_NAME,
              APS_PLAN_LIST,
              APS_PLAN_SEQ,
              APS_DEAL,
              APS_DEAL_DATE,
              APS_DEAL_QTY,
              GROUP_ID,
              MODEL_VERSION,
              ORI_PLAN_WEEK,
              DEMAND_MONTH,
              DEMAND_WEEK,
              SATISFY_WAY,
              DEAL_START_DATE,
              DEAL_END_DATE,
              DEAL_QTY
            ORDER BY APS_PLAN_NO
          ) AS rn
        FROM raw_data
      )
      WHERE rn = 1;
      

      這樣可以用來跟 C# 處理後(去重後)的結果做一比一對照:「哪些原始重複只留一筆的狀況在資料庫那邊也一樣」。



      五、常見錯誤補充:ORA-00942 表或視圖不存在

      如果執行 SQL 遇到:

      ORA-00942: 表格或視觀表不存在
      

      可能原因與排查步驟

      1. 表名拼錯(大小寫/引號注意):

        • Oracle 預設比較不區分大小寫,但如果表用雙引號建立並含小寫,查的時候要「一模一樣」加雙引號。

      2. 不在自己 schema,下沒有 prefix

        • ALL_TABLES 確認在哪個 owner 有這表:

                  SELECT owner, table_name FROM all_tables WHERE table_name IN ('APS_Z_PLAN','APS_Z_LCM');        
        • 若在別人 schema(例如 TWFHCLIFE.APS_Z_PLAN),就加上 schema 前綴:

          FROM TWFHCLIFE.APS_Z_PLAN p
          LEFT JOIN TWFHCLIFE.APS_Z_LCM l ...
          
      3. 沒有權限

        • 即使表存在,當前使用者沒 SELECT 權限也會報錯。請 DBA 給予:

          GRANT SELECT ON TWFHCLIFE.APS_Z_PLAN TO YOUR_USER;
          GRANT SELECT ON TWFHCLIFE.APS_Z_LCM TO YOUR_USER;
          
        • 或建立 synonym 讓你用簡名存取:

          CREATE SYNONYM APS_Z_PLAN FOR TWFHCLIFE.APS_Z_PLAN;
          CREATE SYNONYM APS_Z_LCM  FOR TWFHCLIFE.APS_Z_LCM;
          



      六、實務建議總結(給不熟悉的人)

      1. 資料撈出來 ≠ 最終給前端的筆數:中間如果有做「去重」、「合併條件不一致」或「格式化再比較」,會把資料壓縮。

      2. 先在資料庫(SQL)把資料整理乾淨:JOIN 與過濾最好在 SQL 端做,格式化可視需求再做輸出。

      3. 在程式裡不用把所有欄位都拿去當群組 key:只用能代表「同一筆」的最小集合(像主鍵組合),避免誤把細節格式化後造成的相同值當重複。

      4. 針對重複要比較時,用 SQL 的 analytic function(如 ROW_NUMBER)取每組第一筆,比單純 DISTINCT 更可控。

      5. 遇到找不到表的錯誤時:先查系統字典確認表在哪裡、名稱正確、再確認權限。



      七、延伸:C# 與 SQL 的整合建議

      • 建議把 JOIN 與主要條件移到一個 SQL view 或 stored procedure(例如:VIEW_APSZRESULTA_FOR_VERSION),C# 直接呼叫、只做 DTO 映射與格式化(如果放在 presentation layer),減少 in-memory groupby 造成筆數誤差。

      • 如果需要比較「原始 vs 去重」,可以在 C# 做兩組輸出(原始 resultData 與去重後),做 diff 並記錄原因給 QA/PM。



      結語

      這個典型案例揭示了資料在不同層(DB vs 程式)處理時「語意不一致」的陷阱:SQL 一次拿出完整資料,C# 吃進去又做了過度去重。解法是清楚分工、降低 joint key、不要把格式化結果當內部邏輯依據、並使用可解釋的去重(如 analytic function + 明確 key)。如果您希望,我可以幫您把這套改成一個 reusable 的 view + C# DTO 映射模組,甚至做單元測試對照 800 vs 60 筆的差異來源。

      留言

      這個網誌中的熱門文章

      🛠【ASP.NET Core + Oracle】解決 ORA-00904 "FALSE": 無效的 ID 錯誤與資料欄位動態插入顯示問題

      🛠【實戰排除教學】從 VS Code 的 _logger 錯誤,到 PowerShell 找不到 npm/serve,再到 Oracle ORA-03135 連線中斷——一次搞懂!

      🔎如何在 Oracle PL/SQL 儲存過程中為文字欄位加入換行符號(CHR(10))——以 Updlcmremark 為例