メインコンテンツへスキップ
見出し画像

ServiceNowで複数テーブルの集計が思い通りにいかない理由と3つの突破口【GlideRecord / DB View 実装例付き】

    前回のブログでは、ServiceNowの腹持ちレコードデータの集計から予測分析までを外側の仕組みで実装する記事を書きました。

    そこでも記載しましたが、ServiceNowでは、分析業務における技術課題を有している点を認識しています。
    その課題克服に向けて、2026年2月に買収完了した 分析業務専用プラットフォーム"Pyramid Analytics社"を統合する計画も示しております。

    そこで、今回は、現時点(2026年4月時)におけるServiceNowの分析業務上で課題となる構造を ”解体新書” 的に赤裸々に明かしてゆきます。

    1.本記事の目的

    ServiceNowは強力なワークフロー基盤ですが、データ分析・集計という観点では構造的な課題が見え隠れします。

    そもそも、単一テーブルにおける集計処理では技術課題は出てきません。

    では、どんな時に難しくなってしまうのでしょうか?

    通常のRDBMSであればSQLのJOIN句で複数テーブルを自由に結合できますが、ServiceNowのスクリプト実行環境(GlideRecord / GlideAggregate)はACL制御を前提とした独自APIであり、SQL直書きは原則できません。

    この制約が、特に以下のような要件で壁になります。

    • 申請ヘッダーと明細、製品マスターをまたいだ集計をしたい

    • 承認率・金額サマリーを部門×カテゴリで横断的に出したい

    • ServiceNow外から連携してきたデータと突き合わせて分析したい

    では、何もできないのか? そうではありません。

    テーブル間の繋がり方のパターンを正しく理解をし、技術制約を潜り抜ける実装アプローチがあります。本記事では購買申請(稟議)業務を題材に、以下の3パターンを実装を交えて解説します。

    Pattern A|Reference で繋がっている場合(ドットウォーク)

    Reference フィールドで繋がっているテーブルは「ドットウォーク」記法で参照先フィールドに辿れます。ただし GlideRecord でのレコード単位取得には有効な一方、GlideAggregate での集計には制約があります。「動くはずなのに動かない」という落とし穴を実装例で整理します。

    Pattern B|1:N の逆引き(ヘッダー → 明細)

    ヘッダーから明細を逆引きするパターンはネストループで実装できます。しかしヘッダー件数 × 明細件数分のDBアクセスが発生するため、大量データではパフォーマンスが劣化します。リアルタイム集計が必要な場合は、有償版 RaptorDB Pro の HTAP / Column Store Index による並列処理が現実解の一つです。

    Pattern C|Reference 未定義テーブル間の結合(DB View)

    ServiceNow 上で Reference が定義されていなくても、共通の文字列キーがあれば DB View(sys_db_view) で JOIN を定義できます。DB View は GlideRecord / GlideAggregate から通常テーブルとして扱えるため、Pattern A・B の制約を回避した集計が可能です。

    2.環境準備

    ・ServiceNow PDI インスタンス:Australia
    ・Python:3.13
    ※データ生成のみにPythonは使用しています。

    3.データモデル & レコードデータ生成

    今回の要件を満たすテーブル定義を行います。4つの物理テーブルを使います。
    ・PO Header2:申請WF Txnテーブル
    ・PO Line2:申請WFの明細テーブル
    ・PO Product:製品マスターテーブル
    ・PO Suppiler:仕入れ先マスターテーブル
    ※テーブルの末尾に"2"を入れてるテーブルは、既存テーブル名と緩衝するために、敢えて別文字列名にしてます

    画像

    テーブル定義を作成。

    画像

    続いて、Data Integration 機能で、サクッとデータインポートします。

    データは以下のPythonスクリプトで、取り込み用CSVファイル作成できます。
    【データ作成用Pythonスクリプト】

    import csv
    import random
    from datetime import date, timedelta
    
    # ============================================================
    # 設定
    # ============================================================
    OUTPUT_DIR = "/home/claude/"
    START_DATE = date(2024, 4, 1)
    END_DATE   = date(2026, 3, 31)
    
    DEPARTMENTS = ["営業部", "マーケティング部", "人事部", "経理部", "開発部", "総務部", "購買部", "法務部"]
    APPROVERS   = ["田中部長", "鈴木部長", "佐藤部長", "山田部長", "高橋部長"]
    REQUESTERS  = ["山本太郎", "佐藤花子", "田中一郎", "鈴木明子", "高橋健二",
                   "渡辺幸子", "伊藤誠", "中村美咲", "小林雄介", "加藤由美",
                   "吉田亮", "松本奈々", "井上剛", "木村彩", "清水拓也"]
    
    STATUSES = ["draft", "pending_approval", "approved", "rejected", "cancelled"]
    
    def random_date(start, end):
        return start + timedelta(days=random.randint(0, (end - start).days))
    
    def get_status_weights(req_date):
        if req_date >= date(2026, 1, 1):
            return [0.05, 0.35, 0.40, 0.15, 0.05]
        else:
            return [0.02, 0.05, 0.75, 0.15, 0.03]
    
    # ============================================================
    # 1. 仕入先マスター(x_xxx_supplier)
    # ※ Pattern C用:製品マスターとはReference未定義、supplier_codeで紐づく
    # ============================================================
    suppliers = [
        {"supplier_code": "SUP001", "supplier_name": "東京オフィス機器株式会社",  "region": "関東", "rating": "A", "payment_terms": "30日"},
        {"supplier_code": "SUP002", "supplier_name": "大阪サプライズ商事",        "region": "関西", "rating": "B", "payment_terms": "60日"},
        {"supplier_code": "SUP003", "supplier_name": "名古屋テクノサービス",      "region": "中部", "rating": "A", "payment_terms": "30日"},
        {"supplier_code": "SUP004", "supplier_name": "福岡ITソリューションズ",    "region": "九州", "rating": "C", "payment_terms": "90日"},
        {"supplier_code": "SUP005", "supplier_name": "札幌オフィスサポート",      "region": "北海道","rating": "B", "payment_terms": "60日"},
        {"supplier_code": "SUP006", "supplier_name": "横浜プレミアムサプライ",    "region": "関東", "rating": "A", "payment_terms": "30日"},
        {"supplier_code": "SUP007", "supplier_name": "京都クリエイティブ商会",    "region": "関西", "rating": "B", "payment_terms": "60日"},
        {"supplier_code": "SUP008", "supplier_name": "仙台ビジネスマシン",        "region": "東北", "rating": "B", "payment_terms": "45日"},
    ]
    
    with open(OUTPUT_DIR + "supplier.csv", "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["supplier_code","supplier_name","region","rating","payment_terms"])
        writer.writeheader()
        writer.writerows(suppliers)
    print(f"supplier.csv: {len(suppliers)}件")
    
    # ============================================================
    # 2. 製品マスター(x_xxx_product)
    # ※ supplier_code は文字列フィールド(Referenceではない)→ Pattern C の素材
    # ============================================================
    products = [
        # OA機器
        {"product_code":"PRD001","product_name":"ノートPC(標準モデル)",   "category":"OA機器",   "unit_price":120000, "unit":"台",  "supplier_code":"SUP001"},
        {"product_code":"PRD002","product_name":"ノートPC(ハイスペック)", "category":"OA機器",   "unit_price":220000, "unit":"台",  "supplier_code":"SUP001"},
        {"product_code":"PRD003","product_name":"デスクトップPC",           "category":"OA機器",   "unit_price":150000, "unit":"台",  "supplier_code":"SUP003"},
        {"product_code":"PRD004","product_name":"モニター(24インチ)",     "category":"OA機器",   "unit_price": 35000, "unit":"台",  "supplier_code":"SUP001"},
        {"product_code":"PRD005","product_name":"モニター(32インチ)",     "category":"OA機器",   "unit_price": 65000, "unit":"台",  "supplier_code":"SUP006"},
        {"product_code":"PRD006","product_name":"プリンター(カラー)",     "category":"OA機器",   "unit_price": 80000, "unit":"台",  "supplier_code":"SUP003"},
        {"product_code":"PRD007","product_name":"プリンター(モノクロ)",   "category":"OA機器",   "unit_price": 45000, "unit":"台",  "supplier_code":"SUP003"},
        # オフィス用品
        {"product_code":"PRD008","product_name":"デスクチェア(エルゴノミクス)","category":"オフィス家具","unit_price": 85000,"unit":"脚","supplier_code":"SUP002"},
        {"product_code":"PRD009","product_name":"デスクチェア(スタンダード)", "category":"オフィス家具","unit_price": 35000,"unit":"脚","supplier_code":"SUP002"},
        {"product_code":"PRD010","product_name":"会議用テーブル",           "category":"オフィス家具","unit_price":120000,"unit":"台", "supplier_code":"SUP007"},
        {"product_code":"PRD011","product_name":"ホワイトボード(大)",     "category":"オフィス家具","unit_price": 28000,"unit":"枚", "supplier_code":"SUP007"},
        {"product_code":"PRD012","product_name":"コピー用紙(A4/500枚)",   "category":"消耗品",   "unit_price":   500, "unit":"箱",  "supplier_code":"SUP005"},
        {"product_code":"PRD013","product_name":"ボールペン(10本セット)", "category":"消耗品",   "unit_price":   800, "unit":"セット","supplier_code":"SUP005"},
        {"product_code":"PRD014","product_name":"トナーカートリッジ",       "category":"消耗品",   "unit_price":  8000, "unit":"個",  "supplier_code":"SUP003"},
        # ソフトウェア・サービス
        {"product_code":"PRD015","product_name":"Office365ライセンス(年間)","category":"ソフトウェア","unit_price": 18000,"unit":"ライセンス","supplier_code":"SUP001"},
        {"product_code":"PRD016","product_name":"セキュリティソフト(年間)","category":"ソフトウェア","unit_price":  5000,"unit":"ライセンス","supplier_code":"SUP004"},
        {"product_code":"PRD017","product_name":"クラウドストレージ(年間)","category":"ソフトウェア","unit_price": 12000,"unit":"アカウント","supplier_code":"SUP004"},
        # 外部サービス
        {"product_code":"PRD018","product_name":"社員研修サービス(1日)",  "category":"外部サービス","unit_price":200000,"unit":"回", "supplier_code":"SUP006"},
        {"product_code":"PRD019","product_name":"清掃サービス(月次)",     "category":"外部サービス","unit_price": 50000,"unit":"月", "supplier_code":"SUP008"},
        {"product_code":"PRD020","product_name":"設備保守契約(年間)",     "category":"外部サービス","unit_price":300000,"unit":"件", "supplier_code":"SUP008"},
    ]
    
    with open(OUTPUT_DIR + "product.csv", "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["product_code","product_name","category","unit_price","unit","supplier_code"])
        writer.writeheader()
        writer.writerows(products)
    print(f"product.csv: {len(products)}件")
    
    # ============================================================
    # 3. 購買申請ヘッダー(x_xxx_po_header2)
    # ============================================================
    headers = []
    for i in range(1, 201):
        req_date   = random_date(START_DATE, END_DATE)
        status     = random.choices(STATUSES, weights=get_status_weights(req_date))[0]
        approval_date = ""
        approver      = ""
        if status in ("approved", "rejected"):
            approval_date = (req_date + timedelta(days=random.randint(3, 20))).strftime("%Y-%m-%d")
            approver      = random.choice(APPROVERS)
        headers.append({
            "po_number":     f"PO{str(i).zfill(5)}",
            "department":    random.choice(DEPARTMENTS),
            "requester":     random.choice(REQUESTERS),
            "request_date":  req_date.strftime("%Y-%m-%d"),
            "status":        status,
            "approver":      approver,
            "approval_date": approval_date,
            "purpose":       random.choice(["業務効率化", "設備更新", "新規プロジェクト対応", "定期補充", "緊急対応"]),
            "total_amount":  0,  # 明細集計後に更新
        })
    
    with open(OUTPUT_DIR + "po_header.csv", "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["po_number","department","requester","request_date","status","approver","approval_date","purpose","total_amount"])
        writer.writeheader()
        writer.writerows(headers)
    print(f"po_header.csv: {len(headers)}件")
    
    # ============================================================
    # 4. 申請明細(x_xxx_po_line2)
    # ============================================================
    lines = []
    header_totals = {h["po_number"]: 0 for h in headers}
    product_map   = {p["product_code"]: p for p in products}
    line_seq = 1
    
    for header in headers:
        num_lines = random.randint(1, 5)
        selected_products = random.sample(products, min(num_lines, len(products)))
        for prod in selected_products:
            qty      = random.randint(1, 20)
            unit_price = prod["unit_price"]
            # ±10%の価格ブレを加える(現実感のため)
            negotiated_price = int(unit_price * random.uniform(0.90, 1.10) / 100) * 100
            subtotal = qty * negotiated_price
            header_totals[header["po_number"]] += subtotal
            lines.append({
                "line_number":       f"LN{str(line_seq).zfill(6)}",
                "po_number":         header["po_number"],   # Header への参照キー
                "product_code":      prod["product_code"],  # Product への参照キー
                "quantity":          qty,
                "unit_price":        negotiated_price,
                "subtotal":          subtotal,
                "delivery_date":     (date.fromisoformat(header["request_date"]) + timedelta(days=random.randint(7, 45))).strftime("%Y-%m-%d"),
                "line_status":       random.choice(["pending", "confirmed", "delivered", "cancelled"]) if header["status"] == "approved" else "pending",
            })
            line_seq += 1
    
    with open(OUTPUT_DIR + "po_line.csv", "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["line_number","po_number","product_code","quantity","unit_price","subtotal","delivery_date","line_status"])
        writer.writeheader()
        writer.writerows(lines)
    print(f"po_line.csv: {len(lines)}件")
    
    # ============================================================
    # 5. ヘッダーの total_amount を明細集計で更新
    # ============================================================
    for h in headers:
        h["total_amount"] = header_totals[h["po_number"]]
    
    with open(OUTPUT_DIR + "po_header.csv", "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["po_number","department","requester","request_date","status","approver","approval_date","purpose","total_amount"])
        writer.writeheader()
        writer.writerows(headers)
    
    # ============================================================
    # サマリー出力
    # ============================================================
    print("\n===== データ生成完了 =====")
    print(f"supplier.csv  : {len(suppliers)}件(仕入先マスター)")
    print(f"product.csv   : {len(products)}件(製品マスター)")
    print(f"po_header.csv : {len(headers)}件(申請ヘッダー)")
    print(f"po_line.csv   : {len(lines)}件(申請明細)")
    print(f"\n総申請金額    : ¥{sum(h['total_amount'] for h in headers):,.0f}")
    print(f"平均明細数    : {len(lines)/len(headers):.1f}件/申請")
    print("\n===== テーブル間の結合キー =====")
    print("po_line.po_number    → po_header.po_number  (Reference想定)")
    print("po_line.product_code → product.product_code (Reference想定)")
    print("product.supplier_code → supplier.supplier_code (Pattern C: Reference未定義)")

    CSVの直インポートによる文字化け回避策として、全ファイルを一手間掛かりますが、Excelで保存し直します。

    Data IntegratonでこれらExcelファイルをちゃっちゃとインポートします。

    画像
    トランスフォームマップ

    インポート完了。

    画像
    PO Supplierテーブル
    画像
    PO Productテーブル
    画像
    PO Header2テーブル
    画像
    PO Line2テーブル

    データ環境が完成しました。次の章から、早速各パターンの実技検証をしてゆきましょう! ここからが本番です。

    4.Pattern A:Reference で繋がっているテーブルへのアクセス

    ユースケース

    購買申請システムでは、申請明細(po_line2)から以下のような情報を横断的に取得したい場面があります。

    • 「この明細の申請部門と製品カテゴリを一緒に取得したい」

    • 「部門×製品カテゴリ別の申請金額合計を集計したい」

    po_line2 には以下の Reference フィールドが定義されています。

    po_line2.po_number    → po_header2(申請ヘッダー)
    po_line2.product_code → po_product(製品マスター)

    Reference で繋がっていれば、ServiceNow の「ドットウォーク」を使って参照先のフィールドへ辿ることができます。

    しかし、コード実行を何度も行い調査した結果(ほんとに何度も何度も💦)、、使える場面と使えない場面があるのが実態です。
    利用可否の条件がなかなか想定通りにいかない機能、痺れます・・

    この点を実装を交えて整理します。

    ユースケース① レコード取得時のドットウォーク(動く)

    GlideRecord で1件ずつレコードを取得する場合、ドットウォークは有効です。明細レコードを取得しながら、参照先のヘッダーや製品マスターの情報を同時に取得できます。

    Background Script で実行確認 ー Gloablスコープ指定

    var gr = new GlideRecord('x_XXXX_po_case_0_po_line2');
    gr.setLimit(5);
    gr.query();
    
    while (gr.next()) {
        var lineNo   = gr.getValue('line_number');
        var dept     = gr.po_number.department.toString();      // po_header2.department
        var category = gr.product_code.category.toString();     // po_product.category
        var subtotal = gr.getValue('subtotal');
    
        gs.info(lineNo + ' | ' + dept + ' | ' + category + ' | ¥' + parseInt(subtotal).toLocaleString());
    }

    実行結果:

    LN000001 | 総務部 | OA機器 | ¥519,200
    LN000002 | 人事部 | ソフトウェア | ¥116,000
    LN000003 | 総務部 | 外部サービス | ¥531,000
    LN000004 | 営業部 | OA機器 | ¥741,600
    LN000005 | 開発部 | オフィス家具 | ¥79,800

    このように、po_line2 を起点に gr.po_number.department という記法で参照先フィールドの値を取得できます。フォームのビジネスルールや、レコード単位の処理には有効なアプローチです。

    ユースケース② 複数テーブルにまたがる集計(一筋縄ではいかない)

    「部門×製品カテゴリ別の申請金額合計」のような集計要件になると、話が変わります。

    まず GlideAggregate でドットウォークを使った groupBy を試みます。

    // NG例:groupBy にドットウォークを指定
    var ga = new GlideAggregate('x_918153_po_case_0_po_line2');
    ga.addAggregate('SUM', 'subtotal');
    ga.groupBy('po_number.department');   // ドットウォーク指定
    ga.groupBy('product_code.category');  // ドットウォーク指定
    ga.query();
    
    while (ga.next()) {
        var dept     = ga.getValue('po_number.department');
        var category = ga.getValue('product_code.category');
        var total    = ga.getAggregate('SUM', 'subtotal');
        gs.info(dept + ' | ' + category + ' | ¥' + total);
    }

    実行結果:

     |  | ¥500,281,100

    集計自体は動いているものの、groupBy に指定したドットウォークフィールドの値が取得できず、部門もカテゴリも空のまま総合計だけが返ってきます。

    これは ServiceNow の GlideAggregate の既知の挙動です。

    代替実装:groupBy を sys_id で行い、メモリ上で集約する

    groupBy をドットウォークではなく Reference フィールド名(sys_id)で指定し、参照先の値は ga.フィールド名.参照先フィールド名.toString() で取得します。

    ただしこの場合、同一部門×カテゴリの組み合わせが申請件数分バラバラに出力されるため、メモリ上の map で再集約する必要があります。

    var map = {};
    
    var ga = new GlideAggregate('x_918153_po_case_0_po_line2');
    ga.addAggregate('SUM', 'subtotal');
    ga.groupBy('po_number');      // sys_id でグループ化
    ga.groupBy('product_code');   // sys_id でグループ化
    ga.query();
    
    while (ga.next()) {
        var dept     = ga.po_number.department.toString();
        var category = ga.product_code.category.toString();
        var total    = parseInt(ga.getAggregate('SUM', 'subtotal'));
        var key      = dept + '||' + category;
    
        map[key] = (map[key] || 0) + total;
    }
    
    // 結果出力
    for (var key in map) {
        var parts = key.split('||');
        gs.info(parts[0] + ' | ' + parts[1] + ' | ¥' + map[key].toLocaleString());
    }

    実行結果:

    総務部 | OA機器        | ¥25,755,700
    総務部 | 外部サービス   | ¥34,611,800
    人事部 | OA機器        | ¥38,884,200
    人事部 | ソフトウェア   | ¥869,500
    開発部 | 外部サービス   | ¥40,434,800
    開発部 | OA機器        | ¥36,162,800
    マーケティング部 | OA機器 | ¥30,181,000
    ...

    部門×カテゴリの集計が正しく出力されました。 ふぅ~っ💦
    ここまで考慮してあげないと、できない集計処理・・ 痺れます。 

    さらに画面出力向けに、”UIページに表示させて!” という試みもしてみたのですが、断念しました。。断念理由も以下に纏めます。

    ポイントまとめ

    できること

    • GlideRecord での1件取得時、gr.参照フィールド.参照先フィールド.toString() でドットウォークが使える

    • ビジネスルール・Script Include など、レコード単位の処理には有効

    できないこと・注意点

    • GlideAggregate の groupBy() にドットウォーク記法を指定しても値が取得できない

    • 複数テーブルにまたがる集計はメモリ上の再集約が必要になり、コードが複雑になる

    • データ量が増えるとメモリ消費・処理時間が増大するリスクがある

    • ServiceNow 内(UI Page 等)での集計結果の表示はスコープ制約が障壁になりやすく、実装コストが高い

    この限界をどう超えるか

    ServiceNow 内での集計・表示には構造的な制約があります。本格的な複数テーブル集計や分析結果の可視化が必要な場合は、ServiceNow 外部の分析基盤との連携が現実的な解になります。

    この点については、別記事(Table API × Streamlit × 生成AI)で実装例を紹介しています。

    また WDF / RaptorDB Pro + ZCC を使って Databricks や Snowflake と連携する構成が、エンタープライズ規模での本質的な解となります。

    画像

    5.Pattern B:1:N の逆引き(ヘッダー → 明細)

    ユースケース

    購買申請システムでは、以下のような「ヘッダー起点で明細を集める」要件が発生します。

    • 「承認済み申請ごとに、明細件数と合計金額を確認したい」

    • 「申請ヘッダーの total_amount と明細の subtotal 合計が一致しているか検証したい」

    このパターンは po_header2 → po_line2 という 1:N の逆引きです。

    po_line2 側に po_header2 への Reference(po_number フィールド)が定義されているため、ヘッダーの sys_id で明細を検索することで逆引きができます。

    po_header2(1)
        ↑ po_line2.po_number(Reference)で逆引き
    po_line2(N)
    

    実装:ネストループで逆引き集計

    GlideRecord のネストループで実装します。外側でヘッダーを取得し、内側で明細を逆引きします。

    Background Script で実行確認 ー Gloablスコープ指定

    // Pattern B:po_header2 → po_line2 の逆引き(1:N)
    var header = new GlideRecord('x_xxx_po_header2');
    header.addQuery('status', 'approved');  // 承認済みのみ
    header.query();
    
    while (header.next()) {
        var poNumber = header.getValue('po_number');
        var dept     = header.getValue('department');
    
        // 明細を逆引き
        var lineTotal = 0;
        var lineCnt   = 0;
    
        var line = new GlideRecord('x_xxx_po_line2');
        line.addQuery('po_number', header.getValue('sys_id'));
        line.query();
        while (line.next()) {
            lineTotal += parseInt(line.getValue('subtotal'));
            lineCnt++;
        }
    
        gs.info(
            poNumber + ' | ' + dept +
            ' | 明細' + lineCnt + '件' +
            ' | ¥' + lineTotal.toLocaleString()
        );
    }
    

    実行結果:

    PO00006 | 総務部         | 明細3件 | ¥2,047,400
    PO00046 | マーケティング部 | 明細3件 | ¥1,537,100
    PO00060 | 総務部         | 明細2件 | ¥345,600
    PO00072 | マーケティング部 | 明細2件 | ¥827,500
    PO00109 | 開発部         | 明細2件 | ¥414,700
    ...
    

    ヘッダーごとに明細件数と合計金額が取得できています。


    ポイントまとめ

    できること

    • Reference で繋がっていれば、逆方向(1:N)の逆引きも GlideRecord で実装できる

    • ヘッダー条件(status = approved など)で絞り込みながら明細を集計できる

    注意点・限界

    • ネストループのため、ヘッダー件数 × 明細件数分のDBアクセスが発生する

    • ヘッダー(申請)が100件・明細が平均3件なら300回のクエリが走る

    • 大量データになるとタイムアウト・パフォーマンス劣化のリスクが高い

    現実的な解

    大量データでの逆引き集計が必要な場合、
    ①ETL で 集計用 テーブルに事前集計しておくアプローチ

    Scheduled Script Job(夜間バッチ)
        ↓
    Pattern B のロジックで集計
        ↓
    集計用 テーブル(単一テーブル)に書き込み
        ↓
    GlideAggregate で高速集計・Report/PA で可視化
    

    リアルタイム性が不要であればこの構成で ServiceNow 内で完結できます。

    ETLを作らず、リアルタイム性が必要な場合は
    ②有償版 RaptorDB Proの導入によるHTAP / Cloumn Store Index / 並列処理を入れ、これを基盤構成の支えにするのも手かと。

    加えて、Pattern C 以降で扱う外部分析基盤との連携も現実解になります。

    画像

    6.Pattern C:Reference 未定義テーブル間の結合 — DB View(sys_db_view)

    ユースケース

    購買申請システムでは、製品マスター(po_product)と仕入先マスター(po_supplier)を結合して以下のような分析をしたい場面があります。

    • 「製品カテゴリ×仕入先地域ごとの製品数を把握したい」

    • 「仕入先の評価(rating)別に、どのカテゴリの製品を扱っているか確認したい」

    ところが今回のデータモデルでは、po_product の supplier_code フィールドは String 型であり、po_supplier への Reference として定義されていません。

    po_product.supplier_code  →  String(文字列)
    po_supplier.supplier_code →  String(PK)
    
    ※ SNow 上で Reference 関係は未定義
    ※ ドットウォーク不可
    

    通常の GlideRecord / GlideAggregate ではこの2テーブルを結合できません。

    この問題を解決するのが ServiceNow の DB View(sys_db_view) です。


    DB View とは

    DB View は ServiceNow が提供するデータベースビューの仕組みです。複数のテーブルを JOIN 条件で結合し、1つの仮想テーブルとして GlideRecord / GlideAggregate からアクセスできるようにします。

    po_product(prod)
        ↓ prod.supplier_code = sup.supplier_code で JOIN
    po_supplier(sup)
        ↓
    u_po_product_supplier_view(仮想テーブル)
        → GlideRecord / GlideAggregate で通常テーブルとして扱える
    

    Reference 未定義であっても、共通の文字列キーさえあれば JOIN を定義できるのがポイントです。


    DB View の作成手順

    ① Database view(sys_db_view.list) を開く

    画像

    Globalスコープに切り替えてから sys_db_view.list を開きます。

    注意:DB View は Global スコープで作成してください。 スコープアプリ内で作成するとクロススコープ制限に引っかかる可能性があります。

    ② Database View を新規作成

    ・Name :u_po_product_supplier_view
    ・Label :PO Product Supplier View
    ・Application :Global

    Submit して保存します。

    ③ View Tables を追加

    保存後、View Tables の Related List から New で2テーブルを追加します。

    1本目(po_product):

    ・Table: x_xxx_po_product
    ・Variable prefix :prod
    ・Where clause :prod.supplier_code = sup.supplier_code

    画像

    2本目(po_supplier):

    ・Table :x_xxx_po_supplier
    ・Variable prefix :sup
    ・Where clause :(空白)

    Variable prefix は各テーブルのフィールドを区別するための識別子です。アンダースコアは使用不可なので注意してください(例:prod ○、prod_ ✕)。 Where clause は結合条件です。1本目のテーブルに prod.supplier_code = sup.supplier_code を指定します。

    ④ View Fields を追加

    各 View Table を開いて、表示したいフィールドを View Fields に追加します。

    po_product(prod)のフィールド:

    • product_code

    • product_name

    • category

    • unit_price

    • supplier_code

    画像
    po_product

    po_supplier(sup)のフィールド:

    • supplier_name

    • region

    • rating

    画像
    po_supplier

    完成したDBビュー。

    画像
    DBビューの完成

    ⑤ Try It で動作確認

    DB View のレコード画面から Try It リンクをクリックすると、結合結果がリスト表示されます。

    画像
    結合結果のリスト

    GlideRecord で使う

    DB View は通常のテーブルと同じように GlideRecord でアクセスできます。フィールド名は Variable prefix + _ + フィールド名 の形式になります。

    // Pattern C:DB View を GlideRecord で参照
    var gr = new GlideRecord('u_po_product_supplier_view');
    gr.setLimit(5);
    gr.query();
    
    while (gr.next()) {
        gs.info(
            gr.getValue('prod_category')     + ' | ' +
            gr.getValue('prod_product_name') + ' | ' +
            gr.getValue('sup_supplier_name') + ' | ' +
            gr.getValue('sup_region')
        );
    }
    

    実行結果:

    OA機器      | プリンター(モノクロ)       | 名古屋テクノサービス   | 中部
    外部サービス | 設備保守契約(年間)         | 仙台ビジネスマシン     | 東北
    消耗品      | ボールペン(10本セット)      | 札幌オフィスサポート   | 北海道
    OA機器      | デスクトップPC              | 名古屋テクノサービス   | 中部
    ソフトウェア | セキュリティソフト(年間)   | 福岡ITソリューションズ | 九州
    

    GlideAggregate で集計する

    DB View に対して GlideAggregate も使えます。通常テーブルと同じように groupBy や addAggregate が動作します。

    // Pattern C:DB View に GlideAggregate で集計
    // 製品カテゴリ × 仕入先地域 ごとの製品数
    var ga = new GlideAggregate('u_po_product_supplier_view');
    ga.addAggregate('COUNT');
    ga.groupBy('prod_category');
    ga.groupBy('sup_region');
    ga.orderBy('prod_category');
    ga.query();
    
    while (ga.next()) {
        gs.info(
            ga.getValue('prod_category') + ' | ' +
            ga.getValue('sup_region')    + ' | ' +
            ga.getAggregate('COUNT')     + '件'
        );
    }
    

    実行結果:

    OA機器      | 中部   | 3件
    OA機器      | 関東   | 4件
    オフィス家具 | 関西   | 4件
    ソフトウェア | 九州   | 2件
    ソフトウェア | 関東   | 1件
    外部サービス | 東北   | 2件
    外部サービス | 関東   | 1件
    消耗品      | 中部   | 1件
    消耗品      | 北海道 | 2件
    

    Pattern A・B では動作しなかった groupBy でのフィールド値取得が、DB View では正常に動作しています。


    ポイントまとめ

    できること

    • Reference 未定義のテーブルでも、共通の文字列キーがあれば DB View で JOIN できる

    • GlideRecord / GlideAggregate から通常テーブルと同じようにアクセスできる

    • groupBy でのフィールド値取得も正常に動作する(Pattern A の制約を回避できる)

    注意点・限界

    • DB View は Global スコープで作成する必要がある

    • Variable prefix にアンダースコアは使用不可

    • DB View は読み取り専用。INSERT / UPDATE / DELETE は不可

    • 結合するテーブルが大きい場合はパフォーマンスに注意が必要

    • 3テーブル以上の結合も可能だが、複雑になるほど管理コストが上がる

    Pattern A・B との比較

    画像

    RaptorDB Pro との組み合わせについて

    RaptorDB Pro の Column Store Index は単一テーブルへの集計・スキャン系クエリ(SUM・COUNT・GROUP BY)で効果を発揮します。

    ただし DB View のような JOIN を含むクエリでは、Column Store(列方向圧縮)から JOIN 実行のために行を再構築する(Row Reconstruction)コストが発生します。

    このコストが Column Store の恩恵を少なからず低減するケースがあるため、DB View × RaptorDB Pro の組み合わせで劇的なパフォーマンス改善を高く期待するのは控えた方が良いかもしれません。

    JOIN 後の単一テーブル的な集計や、単一テーブルの大量レコードスキャンの方が RaptorDB Pro の恩恵を受けやすい用途です。

    画像

    まとめ:ServiceNow 内での複数テーブル集計の現実

    Pattern A・B・C を通じて見えてきたのは、ServiceNow 内での複数テーブル集計には構造的な制約が多いという実態です。

    DB View(Pattern C)は Reference 未定義テーブル間の結合という課題を解決できますが、それでも読み取り専用・Global スコープ固定・パフォーマンス上の懸念など、エンタープライズ規模での本格的な分析には限界があります。

    本格的な複数テーブル集計・分析が必要な場合は、以下のアプローチが現実解です。

    • ServiceNow 内完結:Scheduled Job で Staging テーブルに事前集計

    • 外部分析基盤連携:Workflow Data Fabric / RaptorDB Pro + ZCC で Databricks・Snowflake と連携し、SQL で自由に集計

    ※2026年2月のPyramid Analytics社の買収完了に伴う分析機能の統合は本編に含まれておりません。今後機能搭載されてきた際に、別途本編課題へのアップデートを行ってみようと思います。

    如何でしたでしょうか? 今回はServiceNowのデータ分析の勘所となる”複数テーブル”へのアプローチを題材に、”技術的な深堀りをこれでもか!” というレベルまで迫ってみました。

    ここから見えてくる、実装時の課題となりそうな箇所、回避策、実装上の現実解を参考にして頂ければ幸いです。

    次のブログにも乞うご期待ください。では。


     
     
    東京都在住/自動化(テスト・RPAなど)大好き/LLMへAPI接続して遊ぶことが趣味/外資系SaaSの導入向け技術職/中学受験向け我が家塾の経験/Gemini Enterprise / Google ADK / マルチエージェント(A2A・MCP)× エンタープライズ業務実装

    あなたへのおすすめ