星期五, 9月 26, 2025

[Sheets] Query()

在 Google Sheets 內可以透過 Query() 來篩選結果

Query 語法

QUERY(資料, 查詢, [標題])
  • 資料:指定資料範圍
    • 一個欄位資料必須統一是一個資料型態,布林值、數值 (包含 date/time) 或字串
    • 若資料由多個資料型態混合組成,篩選出來結果可能會不如預期
  • 查詢:該查詢必須包在雙引號內,篩選條件值可以參考特定儲存格
  • 標題:資料內有 N  Row 是標題,而不是實際資料
    • 忽略或是 -1 :Sheets 自行判斷
    • 0:沒有標題
    • N:資料內有 N  Row 是標題,EX:2 代表兩個標題

應用範例

資料來源

資料存放在 QueryData Sheet 內

範例一:

=QUERY(QueryData!$A$1:$F$7, "select * where (B <> 'Eng' and F = true) or (C > "&B1&")")

資料:資料範圍以儲存格來記錄,QueryData!$A$1:$F$7 也可以寫成 QueryData!A1:F7
查詢:
  • 第一條件:部門為 Eng (字串必須以單引號包起來) 且為資深人員
  • 第二條件:參考 B1 儲存格 (需要 & 和雙引號包起來) 輸入數值來篩選薪資高於 950 
尋找出滿足任一條件的資料,查詢結果如下圖

範例二:

=QUERY(QueryData!A:F, "select A , B , C , E where A like 'Da%'")

資料:資料範圍以儲存欄 (Column) 來記錄
查詢:使用 like 進行模糊搜尋,文字前兩個字為 Da 並指定顯示姓名、部門、薪資和年紀這四個欄位就好

範例三:

=QUERY(QueryData!A2:F7, "select B, MAX(C) group by B order by MAX(C) desc label B '部門', MAX(C) '該部門最高薪資'")

找出該部門最高薪資並根據部門最高薪資來排序資料,該彙總結果會透過 Label 來顯示標題

範例四:

=QUERY(QueryData!$A$2:$F$7, "select avg(C) pivot B")

統計各部門薪資平均值並轉置結果,pivot 語法會同時兼具 group by 功能
範例五:多層次標題

該範例重點放在 Query() 第三參數,直接記錄官方文件內範例,下圖範例資料範圍內有兩 Row 標題,當
  • Query(資料 , 查詢 , 1) :資料內的第一 Row 是標題
  • Query(資料 , 查詢 , 2) :資料內的第一 和 二 Row 是標題,以一個儲存格來顯示標題文字串接

星期四, 9月 25, 2025

[Sheets] IfError()

在 Google Sheets 內可以透過 IfError() 來判斷是否發生錯誤

IfError 語法

IFERROR(value, [value_if_error])
  • value:非錯誤時就直接回傳
  • value_if_error:當 value 為錯誤時要回傳的值,預設為空白
附註:IFERROR(exp1,exp2) 在邏輯上等於 IF(NOT(ISERROR(exp1)),exp1,exp2)

應用範例

星期三, 9月 24, 2025

[Sheets] Filter()

在 Google Sheets 內可以透過 Filter() 來進行資料篩選

Filter 語法

FILTER(範圍, 條件_1, [條件_2, ...])
  • 範圍:篩選資料範圍,沒有一定要連續範圍
  • 條件_1:列 (Row)、欄 (Column) 或計算結果為 True 或 False
  • 條件_2:可以設定多個篩選條件,以條件一為準則
附註
  • 只能用來單獨篩選列 (Row) 或欄 (Column),要同時篩選列 (Row) 和欄 (Column),請使用兩次 Filter(),把第一次篩選結果當做第二次的資料範圍。
  • 篩選不到資料,會回傳 #N/A

應用範例

官方文件資料
公式一:=FILTER(A2:B6, ISBLANK(C2:C6)),資料呈現範圍並沒有一定要包含篩選條件相關欄為

公式二:=FILTER({A2:A6 , B2:B6 , C2:C6}, ISBLANK(C2:C6) = false , B2:B6 = 2),資料範圍並沒有一定要是連續範圍,該例子是透過陣列來指定資料範圍,且設定多條件
公式三:=FILTER(A2:C6, B2:B6>5),沒有符合篩選條件資料,會以 #N/A 來呈現

星期一, 9月 22, 2025

[Sheets] TextJoin()

在 Google Sheets 內可以透過 TextJoin() 來達到串接字串且能忽略空白

TextJoin 語法

TEXTJOIN(delimiter, ignore_empty, text1, [text2, ...])
  • delimiter:分隔字串,例如:- 或、,可以是空白 ("")
  • ignore_empty:布林值為 true 的話,會排除空白資料
  • text1:文字,可以是字串或字串範圍
  • text2, ... [OPTIONAL]:其他文字

應用範例

星期六, 9月 20, 2025

[Forms] 回應驗證

Google Forms 問題可以設定驗證機制,藉此提高資料收集準確性,而且 [簡答]、[詳答] 還支援 [規則運算式]

點選問題右下角「⋮」,再點選 [回應驗證]
訂購數量 (單次訂購上限為 10 包) 

訂購數量只能能限制在 1 - 10 之間,但萬一使用輸入該區間的小數點 EX:5.2,也是會通過驗證,雖然驗證有整數條件,但一個問題只能設定一個驗證,無法套用多個驗證

選擇數字 => 距離 (這翻譯怪怪的) => 輸入最小值和最大值並輸入自訂錯誤訊息
使用者輸入 5.2 通過驗證,但就不符合商業邏輯
規則運算式

使用 [規則運算式] 來驗證,就可以限制 1 - 10 之間的整數,拜 AI 盛行,現在要取得對應商業邏輯的規則運算式,已經是相對容易啦
使用者輸入 5.2 就會被擋下來囉

星期四, 9月 18, 2025

[Forms] 檔案上傳

Google Forms 問題有 [上傳檔案] 類型可以選擇,但該 Forms 就一定要登錄 Google 帳號來填寫

把問題變更為 [檔案上傳]


當問題選擇上傳檔案,會出現該段提醒訊息


[收集電子郵件地址] 設定選擇 [由作答者手動輸入(Responder Input)],也會有訊息文字提醒,即使是 [不收集 (Do not collect)],也必須登錄 Google 帳號


上傳檔案
  • 僅允許特定檔案類型:啟用後可以限定上傳檔案類型
  • 檔案數量上限:選項為 1、5、10
  • 檔案大小上限:單一檔案大小,預設為 10MB,最小為 1MB、最大為 10GB
  • 所有上傳檔案的加總大小上限:預設為最小 1GB,最大為 1TB,達到該限制表單即不接受回覆

僅允許特定檔案類型啟用後,有八種類型可以多選,目前並沒有官方文件明確指出每個類型允許的副檔名是哪些

點選問題下方文字 [這份表單可接受的檔案大小上限總計為 1GB 的變更] 會跳至,設定 => 回覆 => 所有上傳檔案的加總大小上限,上傳檔案是佔據該表單擁有者帳號 Google Drive 空間,要確定 Google Drive 空間是否足夠

檔案上傳功能會自行在 Google Drive 內建立對應資料夾存放檔案
Google Form 連結的 Sheets 內有開啟 [收集電子郵件地址] 的話,會有 Email 可以識別檔案是誰上傳,萬一沒有的話,也可以在 Forms 內建立識別問題,請上傳者回答問題來達到識別需求,EX:姓名或單位 + 姓名


星期六, 9月 13, 2025

[GAS] Sheets 呼叫自訂函式

在 YT 課程上發現,原來 Sheets 也能呼叫 Google Apps Script 寫的 function,原以為只有網頁可以呼叫而已,該筆記是要把 Sheets 上的使用者名字去識別化

.gs Code
/**
 * 將中文姓名去識別化。
 * @param  {string} originalName 原始姓名,例如:"陳志明"。
 * @return {string} 去識別化後的姓名。
 */
function anonymizeName(originalName) {

  // 檢查傳入的參數是否為字串
  if (typeof originalName !== "string")
    return "";

  const nameLength = originalName.length;
  if (nameLength <= 0)
    return "";

  const mask = "O";
  let firstLetter = originalName[0];
  let lastLetter = originalName[nameLength - 1];

  if (nameLength === 3) {
    // 如果是 3 個字,保留第一個和最後一個字,中間以 "O" 取代
    return firstLetter + mask + lastLetter;
  } else if (nameLength === 4) {
    // 如果是 4 個字,保留第一個和最後一個字,中間以 "OO" 取代
    return firstLetter + mask.repeat(2) + lastLetter;
  }

  // 如果姓名長度超過 4 個字,就只顯示第一個字就好
  return firstLetter + mask.repeat(nameLength - 1);
}

Sheet 內使用自訂函式

在公式欄位內設定呼叫自訂函式並指定傳入欄位,以下圖來說明,就是輸入 = anonymizeName(A2),完成設定後再下拉複製公式

公式設定完成後的去識別化效果

星期五, 9月 12, 2025

[SQL] 無統計資料的資料行

收到 AP 端系統錯誤訊息
執行逾時到期。在作業完成之前超過逾時等待的時間,或是伺服器未回應。使用者已取消作業。陳述式已經結束。
尋找到一段刪除資料的 TSQL Delete 語法,因為涉及多個 Table 資料刪除就相對複雜,從主要語法內發現到 operator 內有 warming - 無統計資料的資料行 (Columns With No Statistics),下圖截圖整理過,只留下重點資訊

查詢該 Index 並沒有發現異常,剛好近期排程更新過統計資訊,反而是發現 Clustered Index 統計資訊竟然完全沒有更新過
看見 [永不 (never)] 字樣,直覺是有選項可以把統計資訊更新 disable,從官方範例 - UPDATE STATISTICS - D. Update statistics by using FULLSCAN and NORECOMPUTE 確定真的可以 disable
USE AdventureWorks2022;
GO

UPDATE STATISTICS Production.Product (Products)
    WITH FULLSCAN, NORECOMPUTE;
GO
使用語法確認並沒有發現統計資訊自動更新被 disable 情況,推論是翻譯問題,應該是從來沒有更新過而已
SELECT
    OBJECT_NAME(object_id) AS TableName,
    Name AS StatName
FROM sys.stats
WHERE no_recompute = 1
更新該 Index 統計資訊後 operator warming 就消失,執行計畫也正常些
UPDATE STATISTICS Table (StatName) WITH FULLSCAN
該 Table 資料很少,大約 20 萬筆左右,但 clustered index 完全沒有觸發自動更新統計資訊也是頗奇怪

星期四, 9月 11, 2025

[OneNote] 筆記本更名

使用 OneNote 要更新筆記本名稱,必須在網頁版本上進行,無法在 PC 版本或是行動裝置 App 上進行

星期三, 9月 10, 2025

[Forms] 收集電子郵件地址

製作 Forms 時發現,設定內有兩個 [收集電子郵件地址] 選項,分別為
  • 設定 => 回覆 => 收集電子郵件地址 (Collect email addresses):適用範圍為該 Form 本身
  • 設定 => 預設值 => 表單預設預定 => 預設為收集電子郵件地址 (Collect email addresses by default):新增 Form 時的 [設定 => 回覆 => 收集電子郵件地址] 初始設定值
收集電子郵件地址有三個選項
  • 不收集 (Do not collect)
  • 已驗證 (Verified):作答者必須登入 Google
  • 由作答者手動輸入(Responder Input):作答者會手動輸入自己的電子郵件地址
個人設計 Forms 會希望在連結 Sheets 內有 Email 當成唯一識別欄位,所以就不會使用 [不收集] 選項

已驗證畫面

建議使用選項,但剛開始使用時,都覺已經登錄 Google 帳號,Form 上幹嘛多此一舉要打勾確認 Email,後來才想到現在一個人都多帳號,確定目前登錄帳號變成是一個必要選項

由作答者手動輸入畫面

使用 [由作答者手動輸入] 選項,最常遇上使用者輸入錯誤窘況,以報名表單為例來說,輸入錯誤可能會造成主辦方困惱,可能都要接近開課日期才會收到使用者來電詢問,沒有收到任何上課通知,萬一使用者沒有意識到且 Forms 設計單純只有 Email 聯絡選項,就變成找不到人

填表後,Sheet 內資料

星期二, 9月 09, 2025

[SSMS] 指令碼精靈 - 編寫索引的指令碼

提供給同事的 Table Scheam 是透過指令碼精靈產生,Sample Data 則是是用 bcp out 和 bulk insert 灌進去,同事實際 run TSQL 語法時告知,在 Local 端都顯得很緩慢,經查發現原來指令碼精靈預設是不會轉出 nonclustered index,要特別去勾選才會建立,以前 Sample Data 少沒有注意到,這次資料量大馬上就被反應,當下是先透過 [SSMS] 產生 Script 把相關 Table Index script 轉出來使用

指令碼精靈 => 進階 => 把 [編寫索引的指令碼] 設定為 true
轉出 Script 就會包含 nonclustered index,下面 script 只留部分資訊而已
USE [AdventureWorks2022]
GO

/****** Object:  Table [Person].[Person]    Script Date: 2025/9/9 下午 02:28:41 ******/

CREATE TABLE [Person].[Person](
	[BusinessEntityID] [int] NOT NULL,
	[PersonType] [nchar](2) NOT NULL,
	[NameStyle] [dbo].[NameStyle] NOT NULL,
	[Title] [nvarchar](8) NULL,
	[FirstName] [dbo].[Name] NOT NULL,
	[MiddleName] [dbo].[Name] NULL,
	[LastName] [dbo].[Name] NOT NULL,
	[Suffix] [nvarchar](10) NULL,
	[EmailPromotion] [int] NOT NULL,
	[AdditionalContactInfo] [xml](CONTENT [Person].[AdditionalContactInfoSchemaCollection]) NULL,
	[Demographics] [xml](CONTENT [Person].[IndividualSurveySchemaCollection]) NULL,
	[rowguid] [uniqueidentifier] ROWGUIDCOL  NOT NULL,
	[ModifiedDate] [datetime] NOT NULL,
 CONSTRAINT [PK_Person_BusinessEntityID] PRIMARY KEY CLUSTERED 
(
	[BusinessEntityID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

/****** Object:  Index [AK_Person_rowguid]    Script Date: 2025/9/9 下午 02:28:42 ******/
CREATE UNIQUE NONCLUSTERED INDEX [AK_Person_rowguid] ON [Person].[Person]
(
	[rowguid] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO

/****** Object:  Index [IX_Person_LastName_FirstName_MiddleName]    Script Date: 2025/9/9 下午 02:28:42 ******/
CREATE NONCLUSTERED INDEX [IX_Person_LastName_FirstName_MiddleName] ON [Person].[Person]
(
	[LastName] ASC,
	[FirstName] ASC,
	[MiddleName] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO

ALTER TABLE [Person].[Person] ADD  CONSTRAINT [DF_Person_NameStyle]  DEFAULT ((0)) FOR [NameStyle]
GO

ALTER TABLE [Person].[Person]  WITH CHECK ADD  CONSTRAINT [FK_Person_BusinessEntity_BusinessEntityID] FOREIGN KEY([BusinessEntityID])
REFERENCES [Person].[BusinessEntity] ([BusinessEntityID])
GO

ALTER TABLE [Person].[Person] CHECK CONSTRAINT [FK_Person_BusinessEntity_BusinessEntityID]
GO

ALTER TABLE [Person].[Person] WITH CHECK ADD  CONSTRAINT [CK_Person_EmailPromotion] CHECK  (([EmailPromotion]>=(0) AND [EmailPromotion]<=(2)))
GO

ALTER TABLE [Person].[Person] CHECK CONSTRAINT [CK_Person_EmailPromotion]
GO

EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'Primary key for Person records.' , @level0type=N'SCHEMA',@level0name=N'Person', @level1type=N'TABLE',@level1name=N'Person', @level2type=N'COLUMN',@level2name=N'BusinessEntityID'
GO

星期三, 9月 03, 2025

[ADO.NET] Merge

發現 Code 有使用上 DataTable.Merge,來了解如何使用它,該筆記只記錄 Merge(DataTable) 用法

該範例重點為 DataTable 是否有設定 Primary Key,完整 Code 如下
namespace MergePractice
{
    internal class Program
    {
        static void Main(string[] args)
        {
            DataTable originPK = GetDataTable(true);
            DataTable modifiedPK = GetDataTable(true);

            modifiedPK.Rows[0]["item"] = "更新資料";

            DataRow row = modifiedPK.NewRow();
            row["id"] = 14;
            row["item"] = "新增欄位";
            modifiedPK.Rows.Add(row);

            originPK.Merge(modifiedPK);

            PrintValues(originPK, "有 PK 狀態");

            DataTable origin = GetDataTable(false);
            DataTable modified = GetDataTable(false);
            origin.Merge(modified);

            PrintValues(origin, "無 PK 狀態");
        }

        private static DataTable GetDataTable(bool includePrimaryKey)
        {
            DataTable dt = new DataTable("Items");

            DataColumn idColumn = new DataColumn("id", typeof(int));
            DataColumn itemColumn = new DataColumn("item", typeof(string));
            dt.Columns.Add(idColumn);
            dt.Columns.Add(itemColumn);

            if (includePrimaryKey)
                dt.PrimaryKey = new DataColumn[] { idColumn };

            DataRow row;
            for (int i = 0; i <= 5; i++)
            {
                row = dt.NewRow();
                row["id"] = i;
                row["item"] = i.ToString();
                dt.Rows.Add(row);
            }

            dt.AcceptChanges();
            return dt;
        }

        private static void PrintValues(DataTable table, string label)
        {
            Console.WriteLine(label);

            foreach (DataRow row in table.Rows)
            {
                string rowMessage = row.RowState.ToString().PadLeft(10, ' ');

                foreach (DataColumn col in table.Columns)
                {
                    rowMessage += "\t " + row[col].ToString();
                }

                Console.WriteLine(rowMessage);
            }
        }
    }
}

有 PK 狀態下,相同 ID 資料可以進行更新和新增
沒有 PK 狀態就兩個 DataTable 合併在一起

星期二, 9月 02, 2025

[GAS] LineBot 回覆訊息

透過 LineBot 回應使用者送出訊息,該筆記很單純把使用者輸入文字加上 From GAS 字樣回應給使用者,主要流程是把 .gs Code 部屬為網頁應用程式取得 url 後,再進入 Line Developer 去啟用和設定 LineBot webhook,.gs Code 內解析 LineBot 透過 POST 送進來的 json 資料

When an event occurs, such as when a user adds your LINE Official Account as a friend or sends a message, the LINE Platform sends an HTTPS POST request to the webhook URL (bot server).  

.gs Code

解析 LineBot 送出來的 json 資料,可以取得使用者輸入文字和 Reply Token,根據 Send reply message 說明,Reply Toekn 有下列限制
  • 只能使用一次
  • 有效時間限制,取得後要盡快使用
也可以取得 UseID 或 GroupID 等資訊,詳見 Webhook Event Objects
function doPost(e) {

  // 從 PropertiesService 取出 LineChannelAccessToken
  let scriptProperties = PropertiesService.getScriptProperties();
  let properties = scriptProperties.getProperties();
  let lineBotChannelAccessToken = properties["LineChannelAccessToken"];

  // 從 Line 聊天室取得的內容
  let contents = JSON.parse(e.postData.contents);
  // 取出 replayToken 才知道要將訊息推還給誰
  let replyToken = contents.events[0].replyToken;
  // 取出使用者發送的文字訊息
  let text = contents.events[0].message.text;

  let payload = {
    replyToken: replyToken,
    messages: [
      {
        "type": "text",
        "text": text + " ( From GAS )"
      }
    ]
  };

  let options = {
    headers: { Authorization: "Bearer " + lineBotChannelAccessToken },
    contentType: "application/json",
    method: "post",
    payload: JSON.stringify(payload)
  };

  let url = "https://api.line.me/v2/bot/message/reply";
  UrlFetchApp.fetch(url, options);
}

部屬為網頁應用程式
Line Developer 上啟用 Webhook

LineBot => Messaging API settings 內啟用 Webhook

測試結果

把 LineBot 加入好友或是群組內,傳遞訊息後就會自動回覆啦