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

Failed

Query1

#

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

V ← (E, A)
查詢保單的生效日
E ← (A, V)
查詢車牌號碼等於5566的保單
A ← (E, V)
查詢 A, B, C, ... 等保單屬性中 display 為 True 者

Query Language

#

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

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

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

Create Entity
✔️
✔️
Read Entity
✔️
✔️
Update Entity
✔️
✔️
Delete Entity
✔️
✔️
Create Attribute
✔️
✔️
Read Attribute
✔️
✔️
Update Attribute
✔️
✔️
Delete Attribute
✔️
✔️
Create Value
✔️
✔️
Read Value
✔️
✔️
Update Value
✔️
✔️
Delete Value
✔️
✔️

Completeness & Incompleteness

#

筆者之所以有機會撰寫此文,是因為 OneDegree 的產品之一 近期需要開發一套強而有力的搜尋引擎,就姑且稱做 吧!然而系統現存的 Legacy 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 搜尋 的保單時,我們應該預期只會搜尋出 ,且可能嘗試寫出類似以下的 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"]}
]
}
]
}

你將發現其實外層的 運算元應該換成 ,或是內層遇到複數欄位時應該要能進行逐項匹配的運算,如此才能得到預期的搜尋結果。但是 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 在複數欄位上提供了 運算元讓我們能夠進行逐項匹配的運算,使得最終結果是正確的,因此可知 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 提供了 語句使得查詢結果符合預期,因此可知 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 的操作:

Create Entity
✔️
✔️
Read Entity
✔️
✔️
✔️
Update Entity
✔️
✔️
Delete Entity
✔️
✔️
Create Attribute
✔️
✔️
Read Attribute
✔️
✔️
✔️
Update Attribute
✔️
✔️
Delete Attribute
✔️
✔️
Create Value
✔️
✔️
Read Value
✔️
✔️
✔️
Update Value
✔️
✔️
Delete Value
✔️
✔️

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 足夠熟悉,應該可以知道它的運算元包含了 , , , , , , , , , , , , , ...,按照運算子的類型分類後便能更加一目瞭然:

  • Entity Level
  • Column Level
  • Binary Expression

因此我們便能夠定義出 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 的預設限制(),且 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 (
...
)
}

參考資料

#

  • MongoDB Query Language (MQL)
  • GraphQL

註釋

#

  1. Designing Queries (Human-Computer Interaction)open_in_new

  2. https://zh.wikipedia.org/wiki/圖靈完備性open_in_new