
一張單據上的訂單日期、一筆稽核紀錄的事件時間、一份班表上的上班時刻,回答的是不一樣的問題。但進了 DataSet,前兩個都是 DateTime 欄,第三個是字串欄,光看欄位型別分不出來,所以「這一欄是什麼」得由定義層另外交代。
畫面要靠它挑編輯控件,資料庫要靠它挑原生型別,用戶端與伺服器之間傳遞時,也要靠它決定要不要做時區轉換(那是明天的題目)。這幾件事都無法只從「它是 DateTime」判斷。
本篇說明:
DataSet 裡同為 DateTime,語意靠什麼保留排班表上的 08:00 跟打卡紀錄上的 08:00 看起來一樣,意思不同:前者每天都適用,後者只代表發生過的那一次。
框架的時間欄位分成三種語意,判別法是問:這個值需不需要知道是哪一天。
| 語意 | 回答的問題 | FieldDbType |
程式裡的型別 | 時區轉換 | 例子 |
|---|---|---|---|---|---|
| 日曆日 | 哪一天 | Date |
DateOnly |
不轉換 | 生日、發票日期、會計期間 |
| 時間點 | 哪一天的幾點 | DateTime |
DateTime |
轉換 | 建立時間、登入時戳、打卡紀錄 |
| 時刻 | 幾點(一日之內) | Time |
TimeOnly |
不轉換 | 班別起訖、營業時間、提醒時刻 |
宣告方式跟其他欄位一樣,一個欄位一行:
<FormField FieldName="hire_date" Caption="到職日" DbType="Date" />
<FormField FieldName="created_at" Caption="建立時間" DbType="DateTime" />
<FormField FieldName="work_start" Caption="上班時刻" DbType="Time" />
DbType 同時決定畫面預設的控件,對應的就是 Day 7 那張欄位對應控件表的第一欄:日曆日與時間點用日期輸入控件,時刻用定寬的時刻輸入控件。欄位若自己指定了控件,就以指定的為準。
DataSet 裡同為 DateTime 的那兩種日曆日與時間點進了 DataSet 都是 DateTime 欄位,DateOnly 的值寫不進去。框架的處理方式如下:
日曆日 Date |
時間點 DateTime |
|
|---|---|---|
DataSet 裡的欄位型別 |
DateTime |
DateTime |
| 欄位上的標記 | Date |
DateTime |
| 取值時 | 用 CDateOnly 取成 DateOnly |
用 CDateTime 取成 DateTime |
兩者型別相同,要靠欄位上的標記區分:程式判斷時先讀標記,沒有標記才看 CLR 型別,而 DateTime 一律判成時間點。
標記只管判讀,不管寫入:程式直接寫 row["hire_date"] = DateTime.Now,時分秒會一起留在 DataSet 裡,只要日期就自己取 .Date。
各種情況下,標記從哪裡來、會不會保留:
| 情況 | 標記 |
|---|---|
| 依定義建立的欄位 | 建立時就掛上 |
| 從資料庫讀回的表格 | 框架依 FormSchema 補上 |
| 自己寫的 SQL | 沒有定義可查,由寫 SQL 的人在查詢上列出日曆日欄位,或事後對表格補標;欄名寫錯會擲例外 |
| 經 API 以 JSON 或 MessagePack 送出 | 另一端會還原 |
沒有標記的日曆日欄會被當成時間點,而且不會報錯,因為它的值本來就是合法的時間點。會在哪裡出問題,明天再談。
時刻在各處的存放方式:
| 在哪裡 | 長什麼樣子 |
|---|---|
| 資料庫 | 定寬 5 碼的字串,例如 "08:30" |
DataSet |
同一個字串 "08:30" |
| 程式取值 | 用 CTimeOnly 轉成 TimeOnly |
寫回 DataSet |
單筆表單上的時刻控件會把 "8:30" 補成 "08:30";其他方式寫入不一定會補,程式寫入要自己用 CTimeString 轉 |
TimeOnly 不能直接放進 DataSet:時刻欄是字串,直接設 TimeOnly 不會擲例外,而是存成目前文化的文字(zh-TW 下是「上午8:30」);若把欄位型別改成 TimeOnly,JSON 與 MessagePack 送表格時又不認得它。所以時刻欄的格式定為 "HH:mm" 字串,取值時再轉成 TimeOnly。
程式裡用 TimeOnly,因為它表示的就是一天之內的時刻:可以直接比較先後、取出時與分,兩個 TimeOnly 相減還能算出時距,例如下班時刻減上班時刻就是工時,跨過午夜的夜班也算得出來。TimeSpan 表示的是一段長度,可以超過一天,也可以是負數,用它存上班時刻,型別擋不住超過一天的值。
資料庫不用原生的時間型別,是因為五家不一致:SQL Server 與 PostgreSQL 的 TIME 是一天之內的時刻,MySQL 的 TIME 是一段長度,值域到正負 838 小時,Oracle 則沒有 TIME;而前三家的驅動讀回來都是 TimeSpan。所以五家一律用字串:
Date |
DateTime |
Time |
|
|---|---|---|---|
| SQL Server | date |
datetime2(7) |
nchar(5) |
| PostgreSQL | date |
timestamp |
char(5) |
| MySQL | DATE |
DATETIME(6) |
CHAR(5) |
| SQLite | DATE |
DATETIME |
VARCHAR(5) |
| Oracle | DATE |
TIMESTAMP(6) |
VARCHAR2(5) |
字串的好處是直接下 SELECT 就看得懂,而且每筆都補零時,字典序就是時間順序,WHERE work_start BETWEEN '08:00' AND '17:00' 直接成立;沒補零的 "8:30" 會排在 "17:00" 後面,這個條件撈不到它。代價是資料庫只會把這一欄報成長度 5 的字串,框架比對結構時得先把定義裡的時刻也當成長度 5 的字串,否則每次升級都會判定有差異。
三種語意的取值方法寫法一致,方法名就是回傳型別,單參數多載一律回 nullable(三支都在 ValueUtilities.Temporal):
DateOnly? day = ValueUtilities.CDateOnly(row["hire_date"]);
DateTime? instant = ValueUtilities.CDateTime(row["created_at"]);
TimeOnly? start = ValueUtilities.CTimeOnly(row["work_start"]);
回 nullable 是因為時刻找不到能代表「未填」的值:default(TimeOnly) 是 00:00,而 00:00 本身就可能是上班時刻,用它代表未填,沒填的欄位會被當成 00:00,也不會報錯。日期雖然可以拿 0001-01-01 代表未填,但這個值很容易被印到報表上、被當成資料,所以日期的兩支也一樣回 nullable。需要非空的值時,用雙參數多載自己指定替代值。儲存時也一樣,時刻沒填存的是空字串,不是 "00:00"。
取值時也會檢查格式與範圍:CTimeOnly("8:30") 可以接受並當成 08:30,"25:00" 與 30 小時的 TimeSpan 都回空值。字串欄位與 TimeSpan 都裝得下超過一天的值,所以範圍由取值方法來擋。
案例的 FormSchema 裡,應用自己的時間欄位只有兩個,而且是同一種:訂單的 order_date 與員工的 hire_date,兩份定義上都寫著 DbType="Date"。
案例的 TableSchema 裡有一批時間點欄位,一個都不在應用自己的表上:session 的寫入與失效時間、API 金鑰的到期時間、定義與快取通知的更新時間、稽核那幾張表各自的事件時間,以及框架其他幾張表的寫入時間。全部是框架自己記錄的資料。
原因是兩種資料的用途不同。訂單日期拿來結帳、分期間、比對月結,幾點下的單在業務上用不到;session 什麼時候失效、哪一秒登入失敗,少了時分秒就沒有意義。用第一節的判別法來看,應用的表要的是日曆日,框架的表要的是時間點。
日曆日與時間點在 DataSet 裡同為 DateTime,靠欄位上的標記區分,沒有標記的欄位會被當成時間點,而且不會報錯。框架建立與讀回的表格都會標上,自寫的 SQL 則要自己宣告日曆日欄位。
時刻在資料庫與 DataSet 裡都是定寬的字串,不會被誤認成時間點,程式取值時再轉成 TimeOnly。代價是資料庫只認得它是長度 5 的字串,框架比對結構時要另外處理。
明天談這三種型別裡唯一會被動到的那一種,也就是時間點的時區轉換。日曆日與時刻不做轉換,轉了只會出錯,例如生日退到前一天、八點上班變成下午四點。前提是先分得出一欄是哪一種。
本系列同步發表於 HackMD,完整目錄