تتطلب قيم Date وTime وTimestamp عناية خاصة، لأن هناك عدة مشكلات شائعة مرتبطة بها.
وأكثر هذه المشكلات شيوعًا هو كيفية التعامل مع المناطق الزمنية. وهناك مشكلة أخرى تتعلق بالتمثيل النصي وكيفية استخدامه.
إضافة إلى ذلك، لكل قاعدة بيانات ولكل برنامج تشغيل خصائصه وقيوده الخاصة.
يهدف هذا المستند إلى أن يكون دليلًا يساعد على اتخاذ القرار، من خلال وصف المهام، وتقديم تفاصيل التنفيذ، وشرح المشكلات.
نعلم جميعًا أن التعامل مع المناطق الزمنية صعب (التوقيت الصيفي والتغييرات المستمرة في الإزاحة الزمنية). لكن هذا القسم يتناول مشكلة أخرى مرتبطة بالمناطق الزمنية: كيف ترتبط بالتمثيل النصي للطابع الزمني.
كيف يحوّل ClickHouse سلاسل DateTime
يستخدم ClickHouse القواعد التالية لتحويل قيم السلاسل النصية من DateTime:
- إذا كان العمود معرّفًا بمنطقة زمنية (
DateTime64(9, ‘Asia/Tokyo’))، فستُعامَل قيمة السلسلة على أنها طابع زمني في تلك المنطقة الزمنية. وستصبح 2026-01-01 13:00:00 هي 2026-01-01 04:00:00 بتوقيت UTC.
- إذا لم يكن للعمود تعريف لمنطقة زمنية، فستُستخدم فقط المنطقة الزمنية الخاصة بالخادم. مهم: لا يؤثر الإعداد
session_timezone على ذلك. لذا، إذا كانت المنطقة الزمنية للخادم هي UTC وكانت المنطقة الزمنية للجلسة هي America/Los_Angeles، فستُكتب 2026-01-01 13:00:00 بتوقيت UTC.
- عند قراءة قيمة من عمود لا يحتوي على تعريف لمنطقة زمنية، تُستخدم
session_timezone، أو المنطقة الزمنية الخاصة بالخادم إذا لم تكن مضبوطة. ولهذا السبب، قد تتأثر قراءة الطوابع الزمنية كسلاسل نصية بـ session_timezone. لا مشكلة في ذلك، ولكن ينبغي أخذه في الاعتبار.
كتابة الطوابع الزمنية عبر المناطق الزمنية
لنفترض الآن أن لدينا تطبيقًا يعمل في المنطقة us-west مع منطقة زمنية محلية هي UTC-8، ونحتاج إلى كتابة طابع زمني محلي 2026-01-01 02:00:00، والذي يقابل في UTC القيمة 2026-01-01 10:00:00:
- تتطلب كتابته كسلسلة نصية تحويله إلى المنطقة الزمنية للخادم أو المنطقة الزمنية للعمود.
- تتطلب كتابته باستخدام بنية زمنية أصلية في اللغة أن يكون برنامج التشغيل على علم بالمنطقة الزمنية المستهدفة، ولكن:
- لا يكون ذلك ممكنًا دائمًا
- واجهة برمجة تطبيقات برنامج التشغيل ليست مصممة جيدًا لهذا الغرض
- والطريقة الوحيدة هي توضيح التحويلات التي ستُجرى حتى يتمكن التطبيق من التعويض (أو كتابة
طابع زمني Unix كرقم)
واجهات Java وJDBC للطابع الزمني
توفّر Java وJDBC طرقًا مختلفة لتعيين طابع زمني:
- استخدم الفئة
Timestamp، وهي في الواقع طابع زمني Unix.
- عند استخدامها مع الكائن
Calendar، يتيح ذلك إعادة تفسير Timestamp وفقًا للمنطقة الزمنية الخاصة بالتقويم.
- تحتوي
Timestamp على تقويم داخلي ليس من السهل ملاحظته.
- استخدم الفئة
LocalDateTime، إذ يسهل تحويلها إلى أي منطقة زمنية، ولكن لا توجد طريقة تتيح تمرير منطقة زمنية مستهدفة.
- استخدم الفئة
ZonedDateTime، التي تساعد في تحويل المنطقة الزمنية عند الكتابة إلى DateTime من دون منطقة زمنية (لأننا نعرف أنه يجب استخدام المنطقة الزمنية للخادم).
- لكن كتابة
ZonedDateTime في عمود ذي منطقة زمنية محددة تتطلب من المستخدم تعويض التحويل الذي يجريه برنامج التشغيل.
- استخدم
Long لكتابة طابع زمني Unix بالميلي ثانية.
- استخدم
String لإجراء جميع التحويلات على جانب التطبيق (وهذا ليس عمليًا جدًا عبر البيئات المختلفة).
يُفضَّل استخدام java.time.ZoneId#of(java.lang.String) عند البحث عن منطقة زمنية بحسب المعرّف.
ستطرح هذه الطريقة استثناءً إذا لم يتم العثور على المنطقة الزمنية (بينما سيتراجع java.util.TimeZone#getTimeZone(java.lang.String) بصمت إلى GMT).الطريقة الصحيحة للحصول على المنطقة الزمنية Tokyo هي:TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo"))
التواريخ بطبيعتها غير مرتبطة بمنطقة زمنية. يوجد النوعان Date وDate32 لتخزين التواريخ. ويستخدم كلا النوعين عدد الأيام منذ Epoch (1970-01-01). يستخدم Date أعدادًا موجبة من الأيام فقط، لذا ينتهي نطاقه عند 2149-06-06. أما Date32 فيتعامل مع أعداد سالبة من الأيام لتغطية التواريخ السابقة لـ 1970-01-01، لكن نطاقه أصغر (من 1900-01-01 إلى 2100-01-01، حيث تمثل القيمة 0 التاريخ 1970-01-01). ويتعامل ClickHouse مع 2026-01-01 على أنه 2026-01-01 في أي منطقة زمنية، ولا توجد معلمة منطقة زمنية في تعريفات الأعمدة.
استخدام java.time.LocalDate
في Java، تُعد الفئة java.time.LocalDate الأنسب لتمثيل قيم التاريخ. يستخدم العميل هذه الفئة لتخزين قيمة أعمدة Date وDate32 (عند القراءة: LocalDate.ofEpochDay((long)readUnsignedShortLE())).
نوصي باستخدام java.time.LocalDate لأنها لا تتأثر بتحويلات المناطق الزمنية، وهي جزء من واجهة برمجة تطبيقات الوقت والتاريخ الحديثة.
ظهر LocalDate في Java 8. وقبل ذلك، كان java.sql.Date يُستخدم لكتابة التواريخ وقراءتها. وداخليًا، تمثل هذه الفئة طبقة تغليف حول لحظة زمنية (أي قيمة وقت تمثل نقطة مطلقة في الزمن). لذلك، تُرجع toString() تاريخًا مختلفًا بحسب المنطقة الزمنية التي تعمل فيها JVM. وهذا يفرض على برنامج تشغيل إنشاء القيم بعناية، كما يتطلب من المستخدم أن يكون على دراية بذلك.
إعادة التفسير المستندة إلى التقويم
تتضمن java.sql.ResultSet طريقة لجلب قيم التاريخ تقبل Calendar، وهناك طريقة مماثلة في java.sql.PreparedStatement. صُمِّم ذلك لإتاحة إعادة تفسير برنامج تشغيل JDBC لقيمة تاريخ في منطقة زمنية المحددة. على سبيل المثال، تحتوي قاعدة البيانات على القيمة 2026-01-01، لكن التطبيق يريد عرض هذا التاريخ على أنه منتصف الليل في Tokyo. وهذا يعني أن الكائن java.sql.Date المُعاد سيُسنَد إليه توقيت زمني محدد، وعند تحويله إلى منطقة زمنية المحلية قد يصبح تاريخًا مختلفًا بسبب فرق التوقيت. ويمكن تحقيق الأمر نفسه باستخدام LocalDate عبر java.time.LocalDate#atStartOfDay(java.time.ZoneId).
يعيد ClickHouse برنامج تشغيل JDBC دائمًا كائن java.sql.Date يشير إلى التاريخ المحلي عند منتصف الليل. وبعبارة أخرى، إذا كان التاريخ هو 2026-01-01، فنحن نقصد 2026-01-01 12:00 AM في منطقة زمنية الخاصة بـ JVM (وهو السلوك نفسه في PostgreSQL وMariaDB JDBC drivers).
تكون قيم Time، مثل قيم Date، غير مرتبطة بمنطقة زمنية في معظم الحالات. ولا يُجري ClickHouse أي تحويلات على القيم الحرفية من نوع Time إلى أي منطقة زمنية — فالقيمة ’6:30’ تبقى نفسها أينما قُرئت.
تم تقديم Time وTime64 في 25.6. قبل ذلك، كان يُستخدم بدلًا منهما نوعا الطابع الزمني DateTime وDateTime64 (وسنناقشهما لاحقًا في هذا الدليل). يُخزَّن Time كعدد صحيح من 32 بت يمثّل عدد الثواني، ويقع ضمن النطاق [-999:59:59, 999:59:59]. ويُرمَّز Time64 على أنه Decimal64 غير موقَّع، ويخزّن وحدات زمنية مختلفة حسب الدقة. والخيارات الشائعة هي 3 (مللي ثانية) و6 (ميكروثانية) و9 (نانوثانية). ويتراوح نطاق قيمة الدقة بين [0, 9].
يقرأ العميل Time وTime64 ويخزّنهما بصيغة LocalDateTime. ويتم ذلك لدعم النطاق الزمني السالب (إذ إن LocalTime لا يدعمه). في هذه الحالة، يكون جزء التاريخ هو تاريخ Epoch 1970-01-01، لذا ستكون القيم السالبة سابقة لهذا التاريخ.
يُنفَّذ الدعم الأساسي لأنواع الوقت باستخدام LocalTime (عندما تكون القيمة ضمن يوم واحد) وDuration للاستفادة من النطاق الكامل للقيم. ويمكن استخدام LocalDateTime للقراءة فقط.
يقتصر استخدام java.sql.Time على نطاق LocalTime. داخليًا، يُحوَّل java.sql.Time إلى قيمة نصية حرفية. ويمكن تغيير هذه القيمة باستخدام المعلمة Calendar مع PreparedStatement#setTime().
الطابع الزمني هو لحظة محددة في الزمن. على سبيل المثال، يمثّل طابع زمني Unix أي لحظة زمنية على هيئة عدد من الثواني بالنسبة إلى 1970-01-01 00:00:00 UTC (ويمثّل العدد السالب من الثواني طابعًا زمنيًا يسبق زمن يونكس، بينما يمثّل العدد الموجب طابعًا زمنيًا يقع بعده). يسهل حساب هذا التمثيل والتعامل معه إذا كان المستخدم ضمن المنطقة الزمنية UTC أو يستخدمها بدلًا من منطقته الزمنية المحلية.
أنواع الطوابع الزمنية في ClickHouse
يوجد في ClickHouse نوعان من الطوابع الزمنية: DateTime (عدد صحيح 32-بت، وتكون الدقة دائمًا بالثواني) وDateTime64 (عدد صحيح 64-بت، وتعتمد الدقة على التعريف). تُخزَّن القيم دائمًا كطوابع زمنية بتوقيت UTC. وهذا يعني أنه عند تمثيلها كأرقام، لا يُجرى أي تحويل للمنطقة الزمنية.
التمثيل النصي وسلوك المنطقة الزمنية
ينطوي التمثيل النصي على بعض التعقيدات:
- إذا لم تُحدَّد منطقة زمنية في تعريف العمود، وتم تمرير سلسلة نصية عند الكتابة، فستُحوَّل من المنطقة الزمنية الخاصة بالخادم إلى رقم طابع زمني بتوقيت UTC. وعند قراءة قيمة من هذا العمود، ستُحوَّل من طابع زمني UTC إلى قيمة طابع زمني حرفية باستخدام المنطقة الزمنية الخاصة بالخادم أو الجلسة (ويُطبَّق نهج مماثل على قيم الطابع الزمني الحرفية في التعبيرات عندما لا تكون المنطقة الزمنية محددة صراحةً).
- إذا كانت المنطقة الزمنية محددة في تعريف العمود، فستُستخدم هذه المنطقة الزمنية وحدها في جميع التحويلات النصية. وهذا يتعارض مع المنطق المتبع عند عدم تحديد منطقة زمنية، لذا يتطلب الأمر فهمًا جيدًا لكيفية كتابة البيانات لكل عمود في الاستعلام.
- إذا تم تمرير تاريخ كسلسلة نصية بتنسيق يتضمن منطقة زمنية، فستكون هناك حاجة إلى دالة تحويل. وعادةً ما يُستخدم
parseDateTimeBestEffort.
كيفية تعامل برنامج تشغيل JDBC مع الطوابع الزمنية
في برنامج تشغيل JDBC، نحوّل الطوابع الزمنية إلى تمثيل رقمي:
"fromUnixTimestamp64Nano(" + epochSeconds * 1_000_000_000L + nanos + ")"
يحل هذا التمثيل معظم مشكلات التحويل المتعلقة بقيم الطابع الزمني لأنه يرسل البيانات إلى الخادم بتنسيق موحّد. ومع ذلك، يتطلب هذا النهج تعديلًا بسيطًا في عبارات SQL، لكنه يوفّر أبسط الطرق وأكثرها مباشرة لكتابة الطوابع الزمنية في أي عمود.
تُقرأ DateTime وDateTime64 وتُخزَّنان لدى العميل بصيغة java.time.ZonedDateTime، مما يساعد على تحويل هذه القيم إلى أي منطقة زمنية أخرى (مع الاحتفاظ بمعلومات المنطقة الزمنية).
من الأخطاء الشائعة عند استخدام toDateTime64
يبدو مثال الشيفرة التالي صحيحًا، لكنه يفشل عند التحقق:
String sql = "SELECT toDateTime64(?, 3)";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
LocalDateTime localTs = LocalDateTime.parse("2021-01-01T01:34:56");
stmt.setObject(1, localTs);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
assertEquals(rs.getObject(1, LocalDateTime.class), localTs);
}
}
يحدث هذا لأن toDateTime64 يستخدم المنطقة الزمنية للخادم ولا يأخذ المنطقة الزمنية للمصدر في الحسبان.
إذا لم يكن زوج التحويل مذكورًا في الجداول أدناه، فهذا يعني أن هذا التحويل غير مدعوم. على سبيل المثال، لا يمكن قراءة أعمدة Date على أنها java.sql.Timestamp لعدم وجود جزء زمني فيها.
لا يحوّل برنامج التشغيل القيم الصحيحة إلى أي قيمة من قيم التاريخ/الوقت. سيؤدي استدعاء pstmt.setLong("timestamp", 1772132359L) إلى كتابة 1772132359 كرقم في الخادم، وسيُعامَل عندها على أنه
طابع زمني Unix بتوقيت UTC وبوحدة الثواني.
كتابة القيم باستخدام PreparedStatement#setObject
يوضح الجدول التالي كيفية تحويل القيم عند ضبطها باستخدام PreparedStatement#setObject(column, value):
فئة value | التحويل |
|---|
java.time.LocalDate | تُنسَّق على هيئة YYYY-MM-DD. |
java.sql.Date | تُحوَّل باستخدام التقويم الافتراضي وتُنسَّق كـ LocalDate (YYYY-MM-DD). |
java.time.LocalTime | تُنسَّق على هيئة HH:mm:ss. |
java.time.Duration | تُنسَّق على هيئة HHH:mm:ss. يمكن أن تكون القيمة سالبة. |
java.sql.Time | تُحوَّل باستخدام التقويم الافتراضي وتُنسَّق كـ LocalTime (HH:mm). |
java.time.LocalDateTime | تُحوَّل إلى Unix timestamp بالنانوثانية وتُغلَّف باستخدام fromUnixTimestamp64Nano. |
java.time.ZonedDateTime | تُحوَّل إلى Unix timestamp بالنانوثانية وتُغلَّف باستخدام fromUnixTimestamp64Nano. |
java.sql.Timestamp | تُحوَّل إلى Unix timestamp بالنانوثانية وتُغلَّف باستخدام fromUnixTimestamp64Nano. |
يجب اعتبار نوع العمود غير معروف. ويعود إلى التطبيق تحديد ما يجب تمريره إلى العبارة المُحضَّرة.
قراءة القيم باستخدام ResultSet#getObject
يوضح الجدول التالي كيفية تحويل القيم عند قراءتها باستخدام ResultSet#getObject(column, class):
نوع بيانات ClickHouse لـ column | قيمة class | التحويل |
|---|
Date or Date32 | java.time.LocalDate | تُحوَّل قيمة DB (عدد الأيام) إلى LocalDate. |
Date or Date32 | java.sql.Date | تُحوَّل قيمة DB (عدد الأيام) إلى LocalDate ثم إلى java.sql.Date باستخدام منتصف الليل في المنطقة الزمنية المحلية كجزء الوقت. وإذا استُخدم تقويم، فستُستخدم منطقته الزمنية بدلًا من المنطقة الزمنية المحلية. مثال: قيمة DB 1970-01-10 → تكون LocalDate هي 1970-01-10. |
Time or Time64 | java.time.LocalTime | تُحوَّل قيمة DB إلى LocalDateTime ثم إلى LocalTime. وينجح ذلك فقط مع الوقت الواقع ضمن يوم واحد. |
Time or Time64 | java.time.LocalDateTime | تُحوَّل قيمة DB إلى LocalDateTime. |
Time or Time64 | java.sql.Time | تُحوَّل قيمة DB إلى LocalDateTime ثم إلى java.sql.Time باستخدام التقويم الافتراضي. وينجح ذلك فقط مع الوقت الواقع ضمن يوم واحد. |
Time or Time64 | java.time.Duration | تُحوَّل قيمة DB إلى LocalDateTime ثم إلى Duration. |
DateTime or DateTime64 | java.time.LocalDateTime | تُحوَّل قيمة DB إلى ZonedDateTime، ثم إلى LocalDateTime. |
DateTime or DateTime64 | java.time.ZonedDateTime | تُحوَّل قيمة DB إلى ZonedDateTime. |
DateTime or DateTime64 | java.sql.Timestamp | تُحوَّل قيمة DB إلى ZonedDateTime، ثم إلى java.sql.Timestamp باستخدام المنطقة الزمنية الافتراضية. |
استخدام الطرائق المعتمدة على التقويم
استخدم ResultSet#getTime(column, calendar) وResultSet#getDate(column, calendar) إذا كانت القيم قد خُزّنت باستخدام PreparedStatement#setTime(param, value, calendar) وPreparedStatement#setDate(param, value, calendar)، على التوالي.