通過這次聯合 (union) 查詢瞭解 Laravel DB 的資料繫結

一冉再發表於2019-04-10

通過這次聯合(union)查詢瞭解 Laravel 預處理的資料繫結

最近做一個需求,業務要求把兩種不同的記錄放在同一個列表中進去篩選查詢,需要查詢兩種記錄關聯的客戶資訊。雖然兩種記錄欄位差距很大,但是好在我們需要的欄位不多,用聯合(union)查詢也能做。

這裡先描述本次實驗的大致情況

  • version: Laravel 5.5
  • Model:
    • customers -> trial_records 一對多
    • customers -> onetoone_records 一對多
  • 需求:
    • trial_recordsonetoone_records 聯合起來並關聯 customers,通過記錄的狀態,記錄的開始時間和客戶的建立時間來篩選查詢結果

第一次嘗試

為了方便對比,這次的查詢條件都是寫死了

聯合查詢

查詢上課時間在 4 月份的,記錄狀態為成功(2)的

$classStartAt = Carbon::parse('2019-04-01 10:07:57')->startOfMonth();
$classEndAt   = Carbon::parse('2019-04-01 10:07:57')->endOfMonth();

$queryOne = DB::table('trial_records')->select([
    'id',
    'customer_id',
    'status',
    DB::raw('@type := 2 as type'),
    'start_at'
]);

$queryTwo = DB::table('onetoone_records')->select([
    'id',
    'customer_id',
    'status',
    DB::raw('@type := 1 as type'),
    'start_time as start_at'
]);

//是的,兩個表用於表示開始時間的欄位名不同
$queryOne->whereBetween('start_at', [$classStartAt, $classEndAt]);
$queryTwo->whereBetween('start_time', [$classStartAt, $classEndAt]);

$queryOne->where('status', 2);
$queryTwo->where('status', 2);

$queryUnion = $queryOne->union($queryTwo);

關聯客戶表

將聯合查詢結果起個別名,通過 customer_id 關聯

$query = DB::table(DB::raw('(' . $queryUnion->toSql() . ') as data'))
->join('customers', function ($q) {
    /** @var JoinClause $q */
    $q->on('customers.id', '=', 'data.customer_id')->whereNull('customers.deleted_at');
});

最後的條件

對客戶的建立時間篩選

$startAt = Carbon::parse('2019-03-01 10:07:57')->startOfMonth();
$endAt   = Carbon::parse('2019-03-01 10:07:57')->endOfMonth();

$query->whereBetween('customers.created_at', [$startAt, $endAt]);

$res = $query->select([
    'customers.name',
    'customers.id as customer_id',
    'customers.created_at',
    'data.status',
    'data.start_at',
    'data.type',
    'data.id',
])->get();

return response()->json($res)->setStatusCode(200)

執行程式

Postman 測試

執行結果

{
    "msg": "SQLSTATE[HY093]: Invalid parameter number (SQL: select `customers`.`name`, `customers`.`id` as `customer_id`, `customers`.`created_at`, `data`.`status`, `data`.`start_at`, `data`.`type`, `data`.`id` from ((select `id`, `customer_id`, `status`, @type := 2 as type, `start_at` from `trial_records` where `start_at` between 2019-03-01 00:00:00 and 2019-03-31 23:59:59 and `status` = ?) union (select `id`, `customer_id`, `status`, @type := 1 as type, `start_time` as `start_at` from `onetoone_records` where `start_time` between ? and ? and `status` = ?)) as data inner join `customers` on `customers`.`id` = `data`.`customer_id` and `customers`.`deleted_at` is null where `customers`.`created_at` between ? and ?)",
    "code": 400,
    "status": "FAILED",
    "data": []
}

SQLSTATE[HY093]: Invalid parameter number (SQL: select customers.name, customers.id as customer_id, customers.created_at, data.status, data.start_at, data.type, data.id from ((select id, customer_id, status, @type := 2 as type, start_at from trial_records where start_at between 2019-03-01 00:00:00 and 2019-03-31 23:59:59 and status = ?) union (select id, customer_id, status, @type := 1 as type, start_time as start_at from onetoone_records where start_time between ? and ? and status = ?)) as data inner join customers on customers.id = data.customer_id and customers.deleted_at is null where customers.created_at between ? and ?)

報錯了,看到爆出錯誤的 sql 中有很多問號,除了第一個start_at 有查詢時間,剩下的都是問號。

分析

為什麼呢?我們都知道問號是預處理的佔位符,這顯然是資料沒有繫結上。

回看程式碼,關聯客戶表的時候,我們將 $queryUnion 的 sql 拼接了,但是原屬於 $queryUnion 的資料繫結並沒有繫結到 $query 上。

OK,找到了原因,我們繼續。

第二次嘗試

修改關聯客戶表這一段,繼續查詢

修改程式碼

mergeBindings

$query = DB::table(DB::raw('(' . $queryUnion->toSql() . ') as data'))
->mergeBindings($queryUnion)
->join('customers', function ($q) {
    /** @var JoinClause $q */
    $q->on('customers.id', '=', 'data.customer_id')->whereNull('customers.deleted_at');
});

執行程式碼

{
    "msg": "操作成功",
    "code": 200,
    "data": [],
    "status": "OK"
}

這次沒報錯,但是空資料了。

檢視日誌

日誌中的 sql 長這樣

select `customers`.`name`,
       `customers`.`id` as `customer_id`,
       `customers`.`created_at`,
       `data`.`status`,
       `data`.`start_at`,
       `data`.`type`,
       `data`.`id`
from ((select `id`, `customer_id`, `status`, @type := 2 as type, `start_at`
       from `trial_records`
       where `start_at` between '2019-04-01 00:00:00' and '2019-04-30 23:59:59'
         and `status` = '2')
      union
      (select `id`, `customer_id`, `status`, @type := 1 as type, `start_time` as `start_at`
       from `onetoone_records`
       where `start_time` between '2019-03-01 00:00:00' and '2019-03-31 23:59:59'
         and `status` = '2019-04-01 00:00:00')) as data
       inner join `customers` on `customers`.`id` = `data`.`customer_id` and `customers`.`deleted_at` is null
where `customers`.`created_at` between '2019-04-30 23:59:59' and '2'

這樣的 sql 能查詢出來資料才怪了,我們可以看到資料的繫結一團亂麻了已經。
為什麼會這樣呢?

原始碼分析

我們檢視 Illuminate\Database\Query\Builder

get()

runSelect 就是查詢

public function get($columns = ['*'])
{
    $original = $this->columns;

    if (is_null($original)) {
        $this->columns = $columns;
    }

    $results = $this->processor->processSelect($this, $this->runSelect());

    $this->columns = $original;

    return collect($results);
}

runSelect()

看到了 getBindings()

protected function runSelect()
{
    return $this->connection->select(
        $this->toSql(), $this->getBindings(), ! $this->useWritePdo
    );
}

getBindings()

屬性 bindings 被轉成一維陣列返回

public function getBindings()
{
    return Arr::flatten($this->bindings);
}

Arr::flatten() 是一個多維陣列轉一位陣列的方法

// Illuminate\Support\Arr
public static function flatten($array, $depth = INF)
{
    $result = [];

    foreach ($array as $item) {
        $item = $item instanceof Collection ? $item->all() : $item;

        if (! is_array($item)) {
            $result[] = $item;
        } elseif ($depth === 1) {
            $result = array_merge($result, array_values($item));
        } else {
            $result = array_merge($result, static::flatten($item, $depth - 1));
        }
    }

    return $result;
}

看來問題的關鍵是 bindings
我們繼續分析這個屬性

bindings

bindings 是一個二維陣列,注意是公開屬性

public $bindings = [
    'select' => [],
    'join'   => [],
    'where'  => [],
    'having' => [],
    'order'  => [],
    'union'  => [],
];

看到這裡我已經大概明白為什麼
bindings 這麼設計並不難理解, laravel 會將所有繫結的資料分好類,這樣即使你的query 沒有按照 select > join > where > having > order > union 這樣的順序來寫,查詢的時候也一樣能夠對應準確的佔位符號。

在我們這個例子中為什麼就發生了偏差呢?
mergeBindings 有什麼問題嗎?

mergeBindings()

這就是遞迴合併了兩個不同 querybingdings,注意這是一個公開的方法

public function mergeBindings(self $query)
{
    $this->bindings = array_merge_recursive($this->bindings, $query->bindings);

    return $this;
}

列印 bindings

修改程式碼列印 bindings

\Log::info($queryUnion->bindings);
$query = DB::table(DB::raw('(' . $queryUnion->toSql() . ') as data'))
    ->mergeBindings($queryUnion)
    ->join('customers', function ($q) {
        /** @var JoinClause $q */
        $q->on('customers.id', '=', 'data.customer_id')->whereNull('customers.deleted_at');
    });
\Log::info($query->bindings);

檢視結果

$queryUnion->bindings

{
    "select": [],
    "join": [],
    "where": [
        {
            "date": "2019-04-01 00:00:00.000000",
            "timezone_type": 3,
            "timezone": "Asia/Shanghai"
        },
        {
            "date": "2019-04-30 23:59:59.999999",
            "timezone_type": 3,
            "timezone": "Asia/Shanghai"
        },
        2
    ],
    "having": [],
    "order": [],
    "union": [
        {
            "date": "2019-04-01 00:00:00.000000",
            "timezone_type": 3,
            "timezone": "Asia/Shanghai"
        },
        {
            "date": "2019-04-30 23:59:59.999999",
            "timezone_type": 3,
            "timezone": "Asia/Shanghai"
        },
        2
    ]
}

$query->bindings

{
    "select": [],
    "join": [],
    "where": [
        {
            "date": "2019-04-01 00:00:00.000000",
            "timezone_type": 3,
            "timezone": "Asia/Shanghai"
        },
        {
            "date": "2019-04-30 23:59:59.999999",
            "timezone_type": 3,
            "timezone": "Asia/Shanghai"
        },
        2
    ],
    "having": [],
    "order": [],
    "union": [
        {
            "date": "2019-04-01 00:00:00.000000",
            "timezone_type": 3,
            "timezone": "Asia/Shanghai"
        },
        {
            "date": "2019-04-30 23:59:59.999999",
            "timezone_type": 3,
            "timezone": "Asia/Shanghai"
        },
        2
    ]
}

看到這裡,其實已經很清楚了。mergeBindings() 只是對兩個 querybindings 的簡單遞迴合併。

在我這裡的情況並不適用,因為我這裡是把 $queryUnion 的結果集作為一個表進行了內連結查詢,在 $queryUnion 中的 bindings 應該全部放到 $query->bindings['join'] 中才對。

明白了原因就能找到應對的方案了。

第三次嘗試

修改關聯查詢程式碼

$queryUnion->bindings 轉成一維陣列,放進 $queryUnion->bindings['join'],然後在合併

$queryUnion->bindings['join'] = Arr::flatten($queryUnion->bindings);

$query = DB::table(DB::raw('(' . $queryUnion->toSql() . ') as data'))
    ->mergeBindings($queryUnion)
    ->join('customers', function ($q) {
        /** @var JoinClause $q */
        $q->on('customers.id', '=', 'data.customer_id')->whereNull('customers.deleted_at');
    });

執行

很好,是我想要的結果

{
    "msg": "操作成功",
    "code": 200,
    "data": [
        {
            "name": "test",
            "customer_id": 93902,
            "created_at": "2019-03-14 14:10:45",
            "status": 2,
            "start_at": "2019-04-11 18:34:03",
            "type": 2,
            "id": 1
        },
        {
            "name": "test",
            "customer_id": 93905,
            "created_at": "2019-03-14 15:10:45",
            "status": 2,
            "start_at": "2019-04-12 20:34:03",
            "type": 1,
            "id": 2
        }
    ],
    "status": "OK"
}

相應的 sql

select `customers`.`name`,
       `customers`.`id` as `customer_id`,
       `customers`.`created_at`,
       `data`.`status`,
       `data`.`start_at`,
       `data`.`type`,
       `data`.`id`
from ((select `id`, `customer_id`, `status`, @type := 2 as type, `start_at`
       from `trial_records`
       where `start_at` between '2019-04-01 00:00:00' and '2019-04-30 23:59:59'
         and `status` = '2')
      union
      (select `id`, `customer_id`, `status`, @type := 1 as type, `start_time` as `start_at`
       from `onetoone_records`
       where `start_time` between '2019-04-01 00:00:00' and '2019-04-30 23:59:59'
         and `status` = '2')) as data
       inner join `customers` on `customers`.`id` = `data`.`customer_id` and `customers`.`deleted_at` is null
where `customers`.`created_at` between '2019-04-01 00:00:00' and '2019-04-30 23:59:59'

這個問題的解決辦法沒有什麼太好的能夠統一解決的,只能說我們需要了解有這個問題,那麼編寫程式碼的時候應當注意,注意避坑。

在我這樣的業務中,我選擇了將第一個 query 中的 bingdings 全部放到 join 段,因為我的業務中,這些資料確實應該存在於第二個 queryjoin 段。

所以遇到這樣的問題,大家還是應該靈活去應對。

當然,如果你有其他的想法,歡迎表達!

相關文章