Virtuoso / Dydra 向けに作られた SPARQL Explorer「Snorql」を Apache Jena Fuseki でも動くようにしました。SPARQL は W3C 標準ですが、エンドポイント実装ごとの挙動差は意外と大きいです。Fuseki 対応で直面した 3 つの問題と、その解決方法を記録します。

開発環境

Docker で Fuseki を起動し、ローカルで検証しました。

# docker-compose.yml
services:
  fuseki:
    image: stain/jena-fuseki
    container_name: fuseki
    ports:
      - "3030:3030"
    environment:
      - ADMIN_PASSWORD=admin
      - FUSEKI_DATASET_1=test
    volumes:
      - fuseki-data:/fuseki

volumes:
  fuseki-data:
docker compose up -d

# テストデータ投入
curl -X POST 'http://localhost:3030/test/data' \
  -H 'Content-Type: text/turtle' \
  --data-binary @testdata.ttl

1. DESCRIBE のレスポンス形式が違う

症状

Fuseki に DESCRIBE クエリを投げると、結果が画面に表示されません。コンソールには JSON パースエラーが出ていました。

調査

SPARQL の DESCRIBE / CONSTRUCT は SELECT と違い、RDF グラフを返します。その形式がエンドポイントによって異なります。

エンドポイントDESCRIBE のレスポンスoutput= パラメータ
Virtuoso / DydraSPARQL Results JSON (独自拡張)
FusekiRDF 形式 (Turtle, RDF/JSON 等)Accept ヘッダより優先される

Snorql は以前から output=json を URL パラメータに付けて送信していました。Fuseki はこれを解釈して JSON-LD を返しますが、Snorql 側は RDF/JSON を期待しているため、パースが壊れます。

curl で切り分けると原因がはっきりします。

# output パラメータなし → Accept ヘッダが効く → RDF/JSON で返る ✓
curl -H 'Accept: application/rdf+json' \
  'http://localhost:3030/test/sparql?query=DESCRIBE+'
# → Content-Type: application/rdf+json

# output=json あり → Accept ヘッダが無視される → JSON-LD で返る ✗
curl -H 'Accept: application/rdf+json' \
  'http://localhost:3030/test/sparql?output=json&query=DESCRIBE+'
# → Content-Type: application/ld+json

Fuseki ではoutput=json が邪魔をしていました。 Accept ヘッダの content negotiation に任せれば正しい形式が返ります。

修正 (5 箇所)

sparql.js — output パラメータの条件付き送信

-urlQueryString += 'output=' + _output + '&';
+if(_output) urlQueryString += 'output=' + _output + '&';

_output が空文字なら output= を送らないようにしました。

sparql.js — Content-Type で JSON を判定

+var isJson = _output == 'json' || (xhr.getResponseHeader('Content-Type') || '').match(/json/i);
-    _output == 'json' ? (JSON.parse(xhr.responseText) || ...
+    isJson ? (JSON.parse(xhr.responseText) || ...

output パラメータを送っていなくても、レスポンスの Content-Type が json を含んでいれば JSON.parse します。Fuseki が application/rdf+json を返した場合に対応しています。

snorql.js — DESCRIBE 時の Accept ヘッダと output 制御

-if(!this.homedef.is_virtuoso) qparam.accept = "application/rdf+json,jsonapplication/json";
+if(!this.homedef.is_virtuoso){
+    qparam.accept = "application/rdf+json,application/json";
+    qparam.output = "";  // output パラメータを送らない
+}

typo の修正 (jsonapplication/json,application/json) と、output を空文字にして Fuseki の content negotiation を有効化しました。

snorql.js — 空文字の output を正しく伝播

-if(output) service.setOutput(output);
+if(output !== undefined) service.setOutput(output);

JavaScript の if(output) は空文字 "" を falsy と評価するため、!== undefined に変更しました。

snorql_def.js — endpoint_type の設定

-endpoint_type: "virtuoso",
+endpoint_type: "fuseki",

2. PREFIX 宣言がないとクエリが通らない

症状

DESCRIBE は表示されるようになりましたが、ラベル取得や関連リソース検索が 400 エラーになります。

Parse error: Unresolved prefixed name: rdfs:label

原因

Snorql は DESCRIBE 表示後、ラベル取得等の補助クエリを内部的に発行します。これらは rdfs:labelschema:name 等のプレフィックス名をそのまま使っています。

エンドポイントPREFIX 宣言
Virtuosordfs: 等を暗黙解決 (宣言不要)
Fusekiすべて明示的な PREFIX 宣言が必要 (W3C 準拠)

Virtuoso の暗黙解決に依存していたコードが、Fuseki では動きませんでした。

修正

snorql_ldb.js — 補助クエリに PREFIX を自動付与

set_service: function(method){
     if(!this.service){
         this.service = new SPARQL.Service(this.endpoint);
         this.service.setMethod(method || "GET");
         this.service.setRequestHeader("Accept", "application/sparql-results+json,*/*");
         this.service.setOutput("json");
+        // namespaces.js の全プレフィックスを登録 → クエリ送信時に自動付与
+        var ns = this.app.snql._namespaces;
+        for(var pfx in ns) this.service.setPrefix(pfx, ns[pfx]);
     }
     return this.service;
 },

SPARQL.Service.setPrefix() で名前空間を登録しておくと、クエリ送信時に PREFIX rdfs: <...> が自動で先頭に付きます。namespaces.js に定義済みの全プレフィックスを一括登録しました。

snorql_ldb.js — null ガード

set_uri: function(qname){
+    if(!qname) return "";
     var nslocal = qname.split(":");

未定義プロパティが undefined として渡された場合の TypeError を防止しています。


3. is_virtuoso フラグが 2 つの仕事をしている

症状

Fuseki に加えて Dydra も並行運用しようとすると、Dydra で 400 エラーが出ました。

failed to parse after 'define' at offset 0 on line 1.
define input:default-graph-exclude

原因

is_virtuoso フラグが 2 つの異なる責務を兼ねていました。

責務場所truthy のとき
レスポンス形式snorql.jsoutput=json を送信
Virtuoso 固有構文util.jsdefine sql:describe-mode "CBD" 等を付与

Dydra は output=json が必要 (Virtuoso と同じレスポンス形式) ですが、Virtuoso 固有の define は受け付けません。is_virtuoso を truthy にすると両方が有効になり、define でエラーになっていました。

修正: 責務の分離

1 つのフラグを 3 つの設定に分けました。

設定役割
endpoint_typeVirtuoso define ディレクティブの ON/OFF
is_virtuosoレスポンス形式 (output=json vs Accept ヘッダ)
describe_as_constructDESCRIBE → CONSTRUCT 自動変換

util.js — define ディレクティブを endpoint_type で判定

-if(!is_virtuoso) return query;
+if(Snorqldef.home?.endpoint_type !== "virtuoso") return query;

util.js — DESCRIBE → CONSTRUCT 自動変換

+if(qtype === "DESCRIBE" && Snorqldef.home?.describe_as_construct){
+    var m = query.match(/\bDESCRIBE\s+(]+>)/i);
+    if(m) query = query.replace(/\bDESCRIBE\s+]+>/i,
+        "CONSTRUCT { " + m[1] + " ?p ?o } WHERE { " + m[1] + " ?p ?o }");
+}

JPS の Virtuoso は DESCRIBE でサーバーエラー (SR452) を返すため、DESCRIBE <uri> を等価な CONSTRUCT に変換するオプションを追加しました。

各エンドポイントの設定

// Virtuoso (JPS)
{ endpoint_type: "virtuoso", is_virtuoso: "default", describe_as_construct: true }

// Dydra
{ endpoint_type: "any", is_virtuoso: "default" }  // output=json あり、define なし

// Fuseki
{ endpoint_type: "fuseki" }  // Accept ヘッダで negotiation、define なし

データフロー (修正後)

[SELECT  全エンドポイント共通]
  output=json  SPARQL Results JSON 

[DESCRIBE  Fuseki]
  Accept: application/rdf+json  output パラメータなし
   Fuseki  RDF/JSON で返す  Content-Type  JSON 判定  パース成功 

[DESCRIBE  Dydra]
  endpoint_type  "virtuoso"  define なし
   output=json  SPARQL Results JSON 

[DESCRIBE  JPS (Virtuoso)]
  describe_as_construct  CONSTRUCT に変換
   output=json  SPARQL Results JSON 

[補助クエリ  ラベル取得等]
  namespaces.js の全 PREFIX を自動付与  Fuseki でも解決可能 

まとめ

SPARQL は標準仕様ですが、実装ごとの差異は意外と多いです。

差異VirtuosoFuseki
DESCRIBE レスポンス独自 JSONW3C 準拠 RDF 形式
PREFIX 解決暗黙解決明示的宣言が必要
独自拡張 (define)ありなし (パースエラーになる)

これらを吸収するために is_virtuoso の二重責務を endpoint_type / is_virtuoso / describe_as_construct の 3 つに分離しました。新しいエンドポイント種別を追加する際は snorql_def.js にフラグを足すだけで、既存の動作は壊れません。

差異の発見には curl での Content-Type 確認と、ブラウザの Network タブが最も役立ちました。仕様上は同じはずの SPARQL でも、レスポンスヘッダを見ると実装の個性がよく分かります。