如何替你的產品設計一套查詢語言?以ODQL: OneDegree Query Language為例

gocreating
OneDegree Tech Blog
17 min readNov 9, 2021

Query

查詢通常會涉及三個主體:Entity, Attribute, Value,如果將這三者類比到關聯式資料庫的話則分別是 Table, Column, Record,下表舉三個最基本的查詢為例:

┌────────────┬───────────────────────────────────────────────┐
│ Notation │ Example │
├────────────┼───────────────────────────────────────────────┤
│ V ← (E, A) │ 查詢保單的生效日 │
│ E ← (A, V) │ 查詢車牌號碼等於5566的保單 │
│ A ← (E, V) │ 查詢 A, B, C, ... 等保單屬性中 display 為 True 者│
└────────────┴───────────────────────────────────────────────┘

Query Language

如果存在一套 Query Language 可以表達所有種類的 Query,則可稱此 Query Language 具有完備性(Query Complete)。

討論程式語言的可計算性時,經常會使用圖靈完備性(Turing Completeness)來描述某程式語言是否能夠表達所有種類的運算,因此本文借用相同概念延伸至 Query Language。

如果我們把所有種類的 Query 視為 Entity, Attribute, ValueCRUD 四大操作的排列組合,便能粗略地歸納 Query Language 的完備性,下表舉 MongoDB Query Language(MQL)及 GraphQL 為例:

┌────────────────────┬─────┬─────────┐
│ Query Completeness │ MQL │ GraphQL │
├────────────────────┼─────┼─────────┤
│ Create Entity │ Yes │ Yes │
│ Read Entity │ Yes │ Yes │
│ Update Entity │ Yes │ Yes │
│ Delete Entity │ Yes │ Yes │
│ Create Attribute │ Yes │ Yes │
│ Read Attribute │ Yes │ Yes │
│ Update Attribute │ Yes │ Yes │
│ Delete Attribute │ Yes │ Yes │
│ Create Value │ Yes │ Yes │
│ Read Value │ Yes │ Yes │
│ Update Value │ Yes │ Yes │
│ Delete Value │ Yes │ Yes │
└────────────────────┴─────┴─────────┘

Completeness & Incompleteness

筆者之所以有機會撰寫此文,是因為 OneDegree 的產品之一 IXT 近期需要開發一套強而有力的搜尋引擎,就姑且稱做 Advanced Filter 吧!然而系統現存的 Legacy API 形式(下稱 Internal API)不夠完備,因此已無法滿足最新的需求了。

The Incompleteness of Internal API

讓我們以車險為例子,具體來說,假設系統中存在兩張車險保單(Policy),其中被保標的(InsuredSubject)分別具有不同的屬性(ExtraDataAttributeValue):

policy_1 = Policy(
insured_subjects=[
InsuredSubject(
extra_data_attribute_values=[
ExtraDataAttributeValue(
attribute_name='car_age',
numeric_value=10
),
ExtraDataAttributeValue(
attribute_name='car_vendor',
string_value='BMW'
)
]
)
]
)
policy_2 = Policy(
insured_subjects=[
InsuredSubject(
extra_data_attribute_values=[
ExtraDataAttributeValue(
attribute_name='car_age',
numeric_value=10
),
ExtraDataAttributeValue(
attribute_name='car_plate',
string_value='OD5566'
)
]
)
]
)

當後台人員在 Advanced Filter 搜尋 車齡 = 10車牌 = OD5566 的保單時,我們應該預期只會搜尋出 policy_2,且可能嘗試寫出類似以下的 Internal API Query:

GET /policies?fields=...&filter={
"and": [
{
"and": [
{"==": ["insured_subjects.extra_data_attribute_values.attribute_name", "car_age"]},
{"==": ["insured_subjects.extra_data_attribute_values.numeric_value", 10]}
]
},
{
"and": [
{"==": ["insured_subjects.extra_data_attribute_values.attribute_name", "car_plate"]},
{"==": ["insured_subjects.extra_data_attribute_values.string_value", "OD5566"]}
]
}
]
}

你將發現其實外層的 and 運算元應該換成 intersect,或是內層遇到複數欄位時應該要能進行逐項匹配的運算,如此才能得到預期的搜尋結果。但是 Internal API 此時此刻並沒有上述的功能,因此可知 Internal API 在 Advanced Filter 所需的功能上並不完備。

The Completeness of MQL

如果將同樣的情況重現於 MongoDB:

db.policy.insertMany([
{
"name": "policy_1",
"insured_subjects": [
{
"extra_data_attribute_values": [
{
"attribute_name": "car_age",
"numeric_value": 10
},
{
"attribute_name": "car_vendor",
"string_value": "BMW"
}
]
}
]
}, {
"name": "policy_2",
"insured_subjects": [
{
"extra_data_attribute_values": [
{
"attribute_name": "car_age",
"numeric_value": 10
},
{
"attribute_name": "car_plate",
"string_value": "OD5566"
}
]
}
]
}
])

你將發現可以透過以下 MQL 實現理想中的查詢:

db.policy.find({
"insured_subjects": {
$elemMatch: {
"extra_data_attribute_values": {
$all: [
{
$elemMatch: {
"attribute_name": "car_age",
"numeric_value": 10
}
},
{
$elemMatch: {
"attribute_name": "car_plate",
"string_value": "OD5566"
}
}
]
}
}
}
})

由於 MQL 在複數欄位上提供了 $elemMatch 運算元讓我們能夠進行逐項匹配的運算,使得最終結果是正確的,因此可知 MQL 在 Advanced Filter 所需的功能上是完備的。

The Completeness of SQL

接著再看同樣的情況重現於關聯式資料庫,以 SQLAlchemy 的 ORM Query 為例:

policy_query_1 = session\
.query(Policy.id, Policy.number)\
.join(Policy.insured_subjects)\
.join(InsuredSubject.extra_data_attribute_values)\
.filter(
and_(
InsuredSubjectExtraDataAttributeValue.attribute_name == 'car_age',
InsuredSubjectExtraDataAttributeValue.numeric_value == 10,
)
)
policy_query_2 = session\
.query(Policy.id, Policy.number)\
.join(Policy.insured_subjects)\
.join(InsuredSubject.extra_data_attribute_values)\
.filter(
and_(
InsuredSubjectExtraDataAttributeValue.attribute_name == 'car_plate',
InsuredSubjectExtraDataAttributeValue.string_value == 'OD5566',
)
)
policies = policy_query_1.intersect(policy_query_2).all()

聰明的你應該已經知道,由於 SQL 提供了 INTERSECT 語句使得查詢結果符合預期,因此可知 SQL 在 Advanced Filter 所需的功能上也是完備的。

OneDegree Query Language

考量翻修 Internal API 的巨大成本,最終與各方 Stakeholders 協議發展新的 Query 方式(下稱 ODQL),最低要求除了要滿足 Advanced Filter 的需求外,ODQL 也必須涵蓋 Internal API 既有的所有功能,並且最好順便補足先前 Internal API 曾經遇過的痛點。

Completeness

就 Advanced Filter 所需的完備性而言,ODQL 僅需完成 Read Only 的部分,暫時無須考量 Create, Update 及 Delete 的操作:

┌────────────────────┬─────┬─────────┬──────┐
│ Query Completeness │ MQL │ GraphQL │ ODQL │
├────────────────────┼─────┼─────────┼──────┤
│ Create Entity │ Yes │ Yes │ No │
│ Read Entity │ Yes │ Yes │ Yes │
│ Update Entity │ Yes │ Yes │ No │
│ Delete Entity │ Yes │ Yes │ No │
│ Create Attribute │ Yes │ Yes │ No │
│ Read Attribute │ Yes │ Yes │ Yes │
│ Update Attribute │ Yes │ Yes │ No │
│ Delete Attribute │ Yes │ Yes │ No │
│ Create Value │ Yes │ Yes │ No │
│ Read Value │ Yes │ Yes │ Yes │
│ Update Value │ Yes │ Yes │ No │
│ Delete Value │ Yes │ Yes │ No │
└────────────────────┴─────┴─────────┴──────┘

Syntax: SQLAlchemy as JSON

在上方的舉例中我們已經看見 SQLAlchemy 可以滿足 Advanced Filter 這個新功能,且先前 Internal API 的實作也全然仰賴 SQLAlchemy,因此只要 ODQL 能夠完美映射成 SQLAlchemy Query,我們將既能滿足新需求,也能相容 Internal API 所能提供的任意查詢。

由於時程壓力,開發團隊沒有充足的時間重新發明 ODQL 的語法,因此優先採用目前發展非常成熟的 JSON 格式作為 Query 語法,這將能替我們節省開發 Syntax Parser 的時間。

至此,如何實作 ODQL 的問題已經被簡化成另一個等價的命題了:

如何將 SQLAlchemy 的 ORM Query 表達成 JSON 格式?

Expression Tree

在多數程式語言裡,我們通常能將 expression 表示成一顆樹狀的結構,稱為 Expression Tree。而 Tree 的一大好處在於我們可以很容易使用 JSON 的形式來表現,因此我們的原命題可以再次修正為:

如何將 SQLAlchemy 的 ORM Query 表達成 Expression Tree?

Operators, Entities and Columns

如果你對 SQLAlchemy Query 足夠熟悉,應該可以知道它的運算元包含了 ==, !=, >, <, >=, <=, and_(), or_(), not_(), q.intersect(), q.union(), q.offset(), q.limit(), ...,按照運算子的類型分類後便能更加一目瞭然:

  • Entity Level:q.intersect(), q.union(), q.offset(), q.limit(), ...
  • Column Level:==, !=, >, <, >=, <=, ...
  • Binary Expression:and_(), or_(), not_(), ...

因此我們便能夠定義出 ODQL 的 Operator Set 以及 Operands 彼此間的 Expression Tree 範式了:

entity_level_query =
{ <entity_name>: column_level_query } |
{ <entity_level_operator>: *(operands ∪ entity_level_queries) }
column_level_query =
{
<column_name>:
{ <column_level_operator>: *operands } |
column_level_query
} |
{ <binary_operator>: *column_level_queries }

例如:

{
"offset()": [
666,
{
"intersect()": [
{
"policies": {
"and_()": [
{
"is_latest": {
"==": true
}
},
{
"effective_time": {
">=": "2021-11-05 00:00:00+00:00"
}
}
]
}
},
{
"policies": {
"not_()": [
{
"product": {
"state": {
"==": "launched"
}
}
}
]
}
}
]
}
]
}

Query Specific Columns

目前為止,雖然我們已經滿足了查詢條件的語法,但是使用者還沒辦法指定最終要取得的欄位,因此必須再次變更範式的設計,納入欄位選取的考量:

entity_level_query =
{
<entity_name>: {
"fields": fields_expression
"filter": column_level_query
}
} |
{ <entity_level_operator>: *(operands ∪ entity_level_queries) }
field_name = "field_name[,field_name]"
fields_expression = "{field_name[fields_expression]}"

例如:

{
"policies": {
"fields": "{id,name,insured_subjects{id},product{state},create_time}",
"filter": ...
}
}

Query Multiple Entities

在 RESTful 的時代,如果要取得多個 Entities 下的內容,通常要連打幾支不同的 API,不僅拉長了 TTI(Time to Interactive),如果應用程式對資料一致性有特殊要求,甚至可能因為 DB 併發導致看到不一致的 Response Data。在參考 GraphQL 的設計之後,ODQL 也納入了 Multiple Entities 的查詢,讓我們再次修改範式:

cross_entity_query = {
"queries": *entity_level_queries
}

Endpoint

考慮與 FE 整合的難易度,連線上仍然採用 HTTP。但是考慮到先前 Internal API 曾經遇過 URL 長度超過 Gunicorn 的預設限制(--limit-request-line 4094),且 ODQL 雖然現階段只有 Read Only,但未來不排除實作 Update & Delete Entities,因此 HTTP Verb 最終選擇了 POST,fields 及 query 則帶在 Request Body:

POST /odql <cross_entity_query>

Implementation

完整實作涉及公司機密,就憑各位讀者的造化了。

Pros & Cons

ODQL 並非萬靈丹,我們開發它只是為了滿足大部分日常需求,雖然它的確補足了 Internal API 的一些不便,卻也產生了需要取捨的成本,以下總結了幾項利弊,讓大家更曉得使用新工具的同時有哪些仍需注意的事項。

優點:

  • 提供良好的查詢完備性
  • Request 長度較不受限
  • 可以一次查詢多個 Entities,且達到 Atomic 的特性
  • 可自訂 Fields 語法(例如:未來可新增語法來支援 Attribute 查詢、未來可新增聚合結果的查詢)

缺點:

  • 由於使用 POST,所以可能需要特別處理 Request Body 的 Logging
  • 查詢結果永遠是一包集合,無法像 RESTful 般查詢單一且特定的 Instance
  • 無現成工具提供 FE 進行整合

Integration with React

目前在 OneDegree 內部以使用 React 為主,因此與 FE 的整合上建議以 React Hook 的介面來串接最為乾淨俐落,且 ODQL 仍為實驗性質的嘗試,不宜與專案建立過多耦合,保持 Functional 的寫法仍是上策。

初期使用上建議以撰寫 Raw Query 為主:

const MyComponent = () => {
const { countries, timezones } = useODQL({
'countries': {
'fields': '{id}',
'filter': {...}
},
'timezones': {
'fields': '{id}',
'filter': {...}
}
})
return (
...
)
}

使用上較為穩定成熟後可以考慮簡易封裝,例如 Builder Pattern:

const MyComponent = () => {
const { QueryBuilder } = useODQL()
policies = QueryBuilder()
.intersect_with_queries([
QueryBuilder()
.set_entity('policies')
.set_fields('{id,number,insured_subjects{id,name}}')
.set_filter({
'is_latest': {
'$eq': true
}
})
.build(),
QueryBuilder()
.set_entity('policies')
.set_fields('{id,number,insured_subjects{id,name}}')
.set_filter({
'create_time': {
'$gt': '2021-11-07 00:00:00+00:00'
}
})
.build()
])
.set_offset(0)
.set_limit(10)
.build()
.fetch()
 return (
...
)
}

參考資料

--

--