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 / Dydra | SPARQL Results JSON (独自拡張) | — |
| Fuseki | RDF 形式 (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:label や schema:name 等のプレフィックス名をそのまま使っています。
| エンドポイント | PREFIX 宣言 |
|---|---|
| Virtuoso | rdfs: 等を暗黙解決 (宣言不要) |
| 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.js | output=json を送信 |
| Virtuoso 固有構文 | util.js | define sql:describe-mode "CBD" 等を付与 |
Dydra は output=json が必要 (Virtuoso と同じレスポンス形式) ですが、Virtuoso 固有の define は受け付けません。is_virtuoso を truthy にすると両方が有効になり、define でエラーになっていました。
修正: 責務の分離
1 つのフラグを 3 つの設定に分けました。
| 設定 | 役割 |
|---|---|
endpoint_type | Virtuoso define ディレクティブの ON/OFF |
is_virtuoso | レスポンス形式 (output=json vs Accept ヘッダ) |
describe_as_construct | DESCRIBE → 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 は標準仕様ですが、実装ごとの差異は意外と多いです。
| 差異 | Virtuoso | Fuseki |
|---|---|---|
| DESCRIBE レスポンス | 独自 JSON | W3C 準拠 RDF 形式 |
| PREFIX 解決 | 暗黙解決 | 明示的宣言が必要 |
独自拡張 (define) | あり | なし (パースエラーになる) |
これらを吸収するために is_virtuoso の二重責務を endpoint_type / is_virtuoso / describe_as_construct の 3 つに分離しました。新しいエンドポイント種別を追加する際は snorql_def.js にフラグを足すだけで、既存の動作は壊れません。
差異の発見には curl での Content-Type 確認と、ブラウザの Network タブが最も役立ちました。仕様上は同じはずの SPARQL でも、レスポンスヘッダを見ると実装の個性がよく分かります。