bitsmind/graphsql

GraphSql是一种类似于Graphql的语法方法,可以轻松地从SQL数据库中读取数据。它建立在Laravel Eloquent ORM之上。

v1.2.5 2024-09-13 09:59 UTC

This package is auto-updated.

Last update: 2024-09-13 10:05:57 UTC


README

概述

GraphSql是一种类似于Graphql的语法方法,可以轻松地从SQL数据库中读取数据。它建立在Laravel Eloquent ORM之上。

在构建API路由时,我们通常面临一个困境。我们需要为不同的目的构建多个API,但数据来自相同的数据库表。假设对于具有10列的products表,我们的前端应用程序中有2个列表。列表1仅显示nameimage。列表2显示namedescription。在这种情况下,我们可能构建2个API来仅返回特定字段,或者构建单个API来返回所有字段。构建2个API需要更长的时间。如果我们构建一个API,我们看到只需要两个字段,但我们正在返回所有10个字段。随着应用程序的增长,这个问题会扩大。

想象一下,你有一个工具,你可以从前端请求后端需要的字段,如{name,image}。API将返回包含nameimage字段的商品列表,或者请求{name,description}以仅获取namedescription,只需一个商品列表API。

这就是GraphSql的功能。

GraphSql仅限于单个表吗?

哈哈,这里是,我们还可以请求相关表中的附加数据。想象一下,我们需要包含每个产品类别名称的产品列表。然后我们请求{name,image,category{name}}。API将返回包含每个product及其仅具有字段namecategory的产品列表。
或者,包含其变体(表:product_variations)的产品列表,variations{*}返回所有字段。
或者,包含其变体计数(表:product_variations),variations.count

我们可以在节点图字符串中添加条件,例如,variations(status=1,color=Blue){*}返回状态为1且颜色为Blue的变体。允许的运算符:=!=>=<=><
或者,variations(status=1,color=Blue).count返回单个产品的状态为1且颜色为Blue的变体的variations_count。或者,variations(status=1,color=Blue).sum.sale返回单个产品的状态为1且颜色为Blue的variations_sum_sale

示例

API

product/list?graph={name,image,category{name}}

响应中的数据

[
  {
     name:"Pressure Cooker",
     image:"/image/pressure_cooker.jpg",
     category_id:1,
     category:{
        id: 1,
        name:"Home Appliance"
     }
  },
  .
  .
  .
]

不用担心输出中的category_idid。我们稍后再讨论。

API

product/list?graph={name,image,category{name},variations{*}}

响应中的数据

[
  {
     name:"Pressure Cooker",
     image:"/image/pressure_cooker.jpg",
     category_id:1,
     category:{
        id: 1,
        name:"Home Appliance"
     },
     variations:[
        {
           id:10,
           color:"Red",
           size:"Small",
           price:2500,
           status:1,
           created_at: ...,
           updated_at: ...,
        }
        .
        .
        .
     ]
  },
  .
  .
  .
]

API

product/list?graph={name,image,category{name},variations(status=1,color=Blue){*}}

响应中的数据

[
  {
     name:"Pressure Cooker",
     image:"/image/pressure_cooker.jpg",
     category_id:1,
     category:{
        id: 1,
        name:"Home Appliance"
     },
     variations:[
        {
           id:12,
           color:"Blue",
           size:"Small",
           price:2500,
           status:1,
           created_at: ...,
           updated_at: ...,
        }
        .
        .
        .
     ]
  },
  .
  .
  .
]

API

product/list?graph={name,variations.count,variations.sum.sale}

响应中的数据

[
  {
     name:"Pressure Cooker",
     variations_count: 4,
     variations_sum_sale: "200"
  },
  {
     name:"Induction Cooker",
     variations_count: 2,
     variations_sum_sale: "80"
  },
  .
  .
  .
]

这里发生了什么?

我们心中产生了几个问题。比如,如何获得category表的数据?或者,如何请求variations{*}节点,从product_variations表中获取数据?

简单的回答:GraphSql使用Eloquent关系来实现这一点

app/Models/Product.php

    public function category():BelongsTo
    {
        return $this->belongsTo(Category::class, 'category_id', 'id');
    }
    
    public function variations (): HasMany
    {
        return $this->hasMany(ProductVariation::class, 'product_id', 'id');
    }

现在有道理了?这就是它们来自哪里。

安装

先决条件

  • Laravel应用程序最小版本8
  • 模型:User,Category,Product,ProductVariation,CartItem等
  • 定义了适当的Eloquent关系

注意

  • 图字符串:{name,image,category{name},variations(status=1,color=Blue){*}}
  • 节点:category{name}variations(status=1,color=Blue){*}
  • 节点标题:categoryvariations
  • 节点属性:*name
  • 节点条件:status=1color=Blue
  • 节点标题是模型中为相关表定义的方法名称
  • 节点属性/属性是列名
  • 特殊节点属性:*表示所有列,_timestamps表示created_atupdated_at
  1. routes/api.php 中创建路由
    use App\Http\Controllers\ProductController;
    use Illuminate\Support\Facades\Route;
    
    Route::prefix('/product')->group(function () {
        Route::get('/list', [ProductController::class, 'getList']);
        Route::get('/{id}', [ProductController::class, 'getSingle']);
    });
    
  2. app/Http/Controllers/ProductController.php 中添加方法
    namespace App\Http\Controllers;
    
    use App\Http\Controllers\Controller;
    use App\Http\Services\ProductService;
    
    class ProductController extends Controller
    {
    
        function __construct (private readonly ProductService $service) {}
    
        public function getList ()
        {
            return response()->json( $this->service->getList());
        }
    
        public function getSingle ($id)
        {
            return response()->json( $this->service->getProduct($id));
        }
    
    
  3. app/Http/Services/ProductService.php 中添加方法
    namespace App\Http\Services;
    
    class ProductService extends Service
    {    
        public function getList (): array
        {
            try {
                $dbQuery = Product::get();
            
                return [
                    'success' => true,
                    'data' => ['products' => $products]
                ];
            }
            catch (\Exception $exception) {
                return [
                    'success' => false,
                    'message' => $exception->getMessage()
                ];
            }
        }
    
        public function getSingle ($id): array
        {
            try {
                $dbQuery = Product::find($id);
            
                return [
                    'success' => true,
                    'data' => ['product' => $product]
                ];
            }
            catch (\Exception $exception) {
                return [
                    'success' => false,
                    'message' => $exception->getMessage()
                ];
            }
        }
    
    

现在让我们看看我们得到了什么

php artisan serve --port=8800

API

http://127.0.0.1:8800/api/product/list

响应

{
   'success': true,
   'data': {
      'products': [
         {
           id:1,
           name:"Pressure Cooker",
           description:"Description ...",
           image:"/image/pressure_cooker.jpg",
           category_id:1,
           brand:"Hitachi",
           status:1,
           tags:"pressure,cooker,...",
           created_at: ...,
           updated_at: ...,
         },
         .
         .
         .
      ]
   }
}

让我们实现 GraphSql。

  1. 安装 GraphSql
    composer require bitsmind/graphsql
    
  2. 将新表 graph_sql_keys 迁移到数据库。我们稍后再讨论这个问题。
    php artisan migrate
    
  3. 更新 app/Http/Services/ProductService.php
    use Bitsmind\GraphSql\QueryAssist;
    
    class ProductService extends Service
    {   
    
         use QueryAssist;
    
         public function getList (): array
         {
             try {
                 $query = [
                     'graph' => '{*}' // Use necessary graph string here.
                 ];
    
                 $dbQuery = Product::query();
                 $dbQuery = $this->queryGraphSQL($dbQuery, $query, new Product);
                 $products = $dbQuery->get();
               
                 return [
                     'success' => true,
                     'data' => ['products' => $products]
                 ];
             }
             catch (\Exception $exception) {
                 return [
                     'success' => false,
                     'message' => $exception->getMessage()
                 ];
             }
         }
         .
         .
         .
    }
    

现在让我们看看我们得到了什么

API

http://127.0.0.1:8800/api/product/list

返回与之前相同的内容。

现在让我们尝试图形字符串并检查我们得到的结果

  • {*}
  • {name,image}
  • {id,name,image,_timestamps}
  • {name,image,category{*}}
  • {name,image,category{name}}
  • {name,image,category{name},variations{*}}
  • {name,image,category{name},variations.count}
  • {name,image,category{name},variations.sum.sale}
  • {name,image,category{name},variations(status=1).count}
  • 你的想象力是无限的

让我们从 API 查询参数中获取字符串

  1. 更新 app/Http/Controllers/ProductController.php
    namespace App\Http\Controllers;
    
    use App\Http\Controllers\Controller;
    use App\Http\Services\ProductService;
    use Illuminate\Http\Request;
    
    class ProductController extends Controller
    {
    
        function __construct (private readonly ProductService $service) {}
    
        public function getList (Request $request): JsonResponse
        {
            return response()->json( $this->service->getList( $request->query()));
        }
    
        public function getSingle ($id, Request $request)
        {
            return response()->json( $this->service->getProduct($id, $request->query()));
        }
    
    
  2. 更新 app/Http/Services/ProductService.php
    use Bitsmind\GraphSql\QueryAssist;
    
    class ProductService extends Service
    {   
    
         use QueryAssist;
    
         public function getList (array $query): array
         {
             try {
    
                 $dbQuery = Product::query();
                 $dbQuery = $this->queryGraphSQL($dbQuery, $query, new Product);   
                 $products = $dbQuery->get();
               
                 return [
                     'success' => true,
                     'data' => ['products' => $products]
                 ];
             }
             catch (\Exception $exception) {
                 return [
                     'success' => false,
                     'message' => $exception->getMessage()
                 ];
             }
         }
         .
         .
         .
    }
    

现在让我们检查使用查询参数得到的结果

API

http://127.0.0.1:8800/api/product/list
http://127.0.0.1:8800/api/product/list?graph={*}

返回相同的内容。

现在让我们尝试图形字符串并检查我们得到的结果

  • {*}
  • {name,image}
  • {id,name,image,_timestamps}
  • {name,image,category{*}}
  • {name,image,category{name}}
  • {name,image,category{name},variations{*}}
  • {name,image,category{name},variations.count}
  • {name,image,category{name},variations.sum.sale}
  • {name,image,category{name},variations(status=1).count}
  • 你的想象力是无限的

同样尝试对 product/{id} API 进行测试。

一些示例 API

// category
http://127.0.0.1:8800/api/category/list?graph={name,description,parent{name}}
http://127.0.0.1:8800/api/category/10?graph={name,description,products{name}}

// product
http://127.0.0.1:8800/api/product/list?graph={name,image,category{name}}
http://127.0.0.1:8800/api/product/2?graph={*,category{name},variations{*}}

// user profile
http://127.0.0.1:8800/api/profile?graph={name,email,phone,addresses{*}}

// cart items (cart_items table should have 'product_id', 'product_variation_id' columns)
http://127.0.0.1:8800/api/cart-item/list?graph={quantity,_timestamps,product{name,image},productVariation{*}}

附加方法

GraphSql 为传统查询提供了一些简写

http://127.0.0.1:8800/api/product/list?page=1&length=10&order_by=name,asc&status=1&category_id=1&brand=Hitachi,LG&graph={name,image,category{name}}

这里我们有了可选的 paginationstatuscategory_id 列筛选、brand 列的多选项筛选、按任何列排序

让我们首先看看典型的实现

app/Http/Services/ProductService.php

use Bitsmind\GraphSql\QueryAssist;

class ProductService extends Service
{   
     use QueryAssist;

     public function getList (array $query): array
     {
         try {

             $dbQuery = Product::query();
             
             // graphSql
             $dbQuery = $this->queryGraphSQL($dbQuery, $query, new Product);  
             
             // sorting
             if (array_key_exists('order_by', $query)) {
                 [$column, $order] = explode(',',$query['order_by']);
                 $dbQuery = $dbQuery->orderby($column, $order);
             }
             else {
                 // default
                 $dbQuery = $dbQuery->orderby('id', 'desc');
             }
             
             // column filters
             if (array_key_exists('status', $query)) {
                 $dbQuery = $dbQuery->where('status', $query['status'])
             }
             if (array_key_exists('category_id', $query)) {
                 $dbQuery = $dbQuery->where('category_id', $query['category_id'])
             }
             
             // multi-options filters
             if (array_key_exists('brand', $query)) {
                 $options = explode(',', $query[$field]);
                 $dbQuery = $dbQuery->whereIn('brand', $options);
             }
             
             // pagination
             $count = $dbQuery->count();
             if (!array_key_exists('page', $query))      $query['page']      = 1;
             if (!array_key_exists('length', $query))    $query['length']    = 100;
             $offset = ($query['page']-1)*$query['length'];                
             $products = $dbQuery->offset($offset)->limit($query['length'])->get();
           
             return [
                 'success' => true,
                 'data' => [
                     'page' => $query['page'],
                     'length' => $query['length'],
                     'count' => $count,
                     'products' => $products
                 ]
             ];
         }
         catch (\Exception $exception) {
             return [
                 'success' => false,
                 'message' => $exception->getMessage()
             ];
         }
     }
}

GraphSql 简写

app/Http/Services/ProductService.php

use Bitsmind\GraphSql\QueryAssist;

class ProductService extends Service
{   
     use QueryAssist;

     public function getList (array $query): array
     {
         try {

             $dbQuery = Product::query();
             
             $dbQuery = $this->queryGraphSQL($dbQuery, $query, new Product);           // graphSql
             $dbQuery = $this->queryOrderBy($dbQuery, $query, 'id', 'desc');           // sorting (default id,desc)
             $dbQuery = $this->queryWhere($dbQuery, $query, ['status','category_id']); // column filters
             $dbQuery = $this->queryWhereIn($dbQuery, $query, ['brand']);              // multi-option filters
             
             $count = $dbQuery->count();
             $products = $this->queryPagination($dbQuery, $query)->get();              // pagination
           
             return [
                 'success' => true,
                 'data' => [
                     'page' => $query['page'],
                     'length' => $query['length'],
                     'count' => $count,
                     'products' => $products
                 ]
             ];
         }
         catch (\Exception $exception) {
             return [
                 'success' => false,
                 'message' => $exception->getMessage()
             ];
         }
     }
}

太棒了!

注意

如果你有敏感数据,那么允许直接使用图形字符串是有风险的。

如何?

想象一个有身份验证的系统。因此,任何用户都不允许访问其他用户的数据。但是看看下面的 API 调用

http://127.0.0.1:8800/api/product/2?graph={*,orderItems{*,order{*,user{*}}}}

这个 API 将返回产品的数据,无论订单是来自这个用户还是其他用户。

那么解决方案是什么?

GraphSql 为此提供了现成的解决方案

解决方案 1:Graphsql 关键映射
解决方案 2:Graphsql 字符串加密

我们可以映射所有字符串,然后使用它们的映射键或使用加密字符串。

1. GraphSql 关键映射

还记得我们安装时迁移的 graph_sql_keys 表吗?我们将把我们的图形字符串保存到该表中,并分别为字符串设置一个键:customer_product_list{name,image,category{name}} 分别在 keystring 列中。我们将在 API 中使用 graph_key 而不是 graph 查询参数。

表: graph_sql_keys

让我们设置图形键的 CRUD

  1. routes/api.php 中创建路由。建议将 API 保持为私有。
    use App\Http\Controllers\ProductController;
    use Illuminate\Support\Facades\Route;
    
    Route::middleware('auth:api')->prefix('/graph-sql-key')->group(function () {
       Route::get('/list', [GraphSqlKeyController::class, 'getList']);
       Route::post('/sync', [GraphSqlKeyController::class, 'sync']);
    });
    
  2. 添加控制器 app/Http/Controllers/GraphSqlKeyController.php
    namespace App\Http\Controllers;
    
    use App\Http\Controllers\Controller;
    use App\Http\Services\ProductService;
    
    class GraphSqlKeyController extends Controller 
    {
       function __construct (private readonly GraphSqlKeyService $service) {}
    
       public function getList (): JsonResponse
       {
           return response()->json( $this->service->getList());
       }
    
       public function sync (GraphSqlKeySyncRequest $request): JsonResponse
       {
           return response()->json( $this->service->syncGraphSqlKey( $request->all()));
       }
    }
    
  3. 添加服务 app/Http/Services/GraphSqlKeyService.php
    namespace App\Http\Services;
    
    use Bitsmind\GraphSql\Models\GraphSqlKey;
    
    class GraphSqlKeyService
    {
       public function getList(): array
       {
           try {
               $graphSqlKeys = GraphSqlKey::orderBy('key','asc')->get();
    
                return [
                    'success' => true,
                    'data' => ['graphSqlKeys' => $graphSqlKeys]
                ];
           } catch (\Exception $exception) {
               return [
                    'success' => false,
                    'message' => $exception->getMessage()
                ];
           }
       }
    
       public function syncGraphSqlKey(array $data): array
       {
           try {
               $graphSqlKey = GraphSqlKey::where('key', $data['key'])->first();
               if ($graphSqlKey) {
                   $graphSqlKey->update([
                       'string' => $data['string']
                   ]);
               }
               else {
                   GraphSqlKey::create([
                       'key' => $data['key'],
                       'string' => $data['string']
                   ]);
               }
    
                return [
                    'success' => true,
                    'message' => 'GraphSql Key Synced Successfully'
                ];
           } catch (\Exception $exception) {
               return [
                    'success' => false,
                    'message' => $exception->getMessage()
                ];
           }
       }
    }
    
    

使用 $this->queryGraphSQLByKey 而不是 $this->queryGraphSQL

现在 API 调用

http://127.0.0.1:8800/api/product/list?graph_key=customer_product_list

2. GraphSql 字符串加密

图形字符串可以被加密并发送为查询参数。记住,加密是昂贵的。

  1. 在前端使用此加密函数首先加密字符串

     //js
     function encrypt (str, secret) {
         const refCharSet =',_.-=><*:!(){}[]0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
         const refCharArray = refCharSet.split('')
    
         let shiftStrSet = secret.split('.')
    
         let encryptedStr = '';
         // cipher
         for (let i = 0; i < str.length; i++) {
    
             let shift = shiftStrSet[0][i % shiftStrSet[0].length].charCodeAt(0);
             shift = shift >= 48 && shift <= 57 ? shift - 48 : shift % refCharSet.length
    
             let index = refCharArray.indexOf(str.charAt(i))
    
             if (index > -1) {
                 encryptedStr += refCharArray[(index + shift) % refCharSet.length];
             } else {
                 encryptedStr += str.charAt(i);
             }
         }
    
         for (let sss=1; sss<shiftStrSet.length; sss++) {
             //scramble
             let charArray = encryptedStr.split('');
    
             for (let i = 0; i < encryptedStr.length; i++) {
                 let shift = shiftStrSet[sss][i % shiftStrSet[sss].length].charCodeAt(0);
    
                 let newIndex = shift >= 48 && shift <= 57 ? shift - 48 : shift % encryptedStr.length;
    
                 [charArray[i], charArray[newIndex]] = [charArray[newIndex], charArray[i]];
             }
    
             encryptedStr = charArray.join('');
         }
    
         return encryptedStr
     }
    
  2. .env 中设置一个密钥。密钥由由 . 分隔的字母数字字符串组成。例如:cipher.scramble1st.scramble2nd.scramble3rd 第一部分生成字符串的密文,后续部分对密文进行混淆。建议使用两个部分的密钥。增加更多的部分将生成更复杂的加密。在前端加密时也使用该密钥

    GRAPHSQL_SECRET=Gxe44Ybneaexc74scescet3.DcYxw4a5
    
  3. app/Http/Services/ProductService.php 中使用 $this->queryGraphSQLEncrypted 而不是 $this->queryGraphSQL

现在 API 调用

let graph = '{name,image,category{name}}'
let secret = 'Gxe44Ybneaexc74scescet3.DcYxw4a5'
let graphEnc = encrypt(graph, secret)
http://127.0.0.1:8800/api/product/list?graph_enc=${graphEnc}

对于开发环境,我们通常不需要加密。在这种情况下,我们可以使用 GRAPHSQL_SECRET=0。然后加密字符串将与原始图形字符串相同。这有助于我们在开发环境中轻松调试。

使用键映射需要管理增删改查操作或手动更新表格。从CPU的角度来看,使用加密稍微有些昂贵。

因此,选择最适合您的一个。