Repository navigation
Traqula migration
Migrating the browser's SPARQL parsing and generation off the deprecated SPARQL.js 2.x onto Traqula (@traqula/parser-sparql-1-1, @traqula/generator-sparql-1-1, and @traqula/rules-sparql-1-1 for its AstFactory).
sparqljs is marked deprecated on npm (3.7.4, February 2026). LinkedDataHub bundles sparqljs 2.x as js/SPARQL.js, two generations back, and since #401 retired SPARQLBuilder it is the only SPARQL parser and generator in the browser, so this is a single-library swap. Traqula is the Comunica project's successor, and its own migration guide says it "does not form a drop in replacement on AST level".
What stays: the pipeline. Parse to JSON in JS, json-to-xml, rewrite with XSLT modes, xml-to-json, generate in JS. The 19 modes in client/query-transforms.xsl keep their names, parameters and callers.
What changes: the vocabulary of the tree every mode matches on and emits, the term representation everywhere, the server-side twin ParseQuery.java, the update builder, the script that ships, and the glue params in client.xsl.
- Straight to Traqula, skipping SPARQL.js 3.x. 3.x would cost the same term-representation rewrite and is itself deprecated.
- XSLT written against the Traqula tree directly. No JS adapter: an adapter is a hand-maintained library again, which is what #401 just removed.
-
No round-tripping, for now. The parser is built with an
AstFactory({ tracksSourceLocation: false }), so every parsed node carriesloc: { sourceLocationType: 'autoGenerate' }, and every node the XSLT builds carries the same marker. The generator then regenerates the whole query from the tree, which is exactly what SPARQL.js did. Round-tripping (verbatim reproduction of untouched regions viaorigSource) is a later, optional phase; it is what would make the sibling-GRAPH merge class of bug (see the comment inclient/tree.xsl) impossible. -
Prefixes resolved in XSLT. Traqula does not expand
foaf:name; a parsed prefixed IRI is{ value: 'name', prefix: 'foaf' }and the query'scontextarray holds the definitions. One functionldh:iri($term, $context)returns the full IRI, used wherever a predicate or value is compared against a URI.
Terms. The whole tree uses these, including expressions and VALUES rows. Every node also carries loc, omitted below.
| SPARQL.js 2.x (string) | Traqula (node) |
|---|---|
?name |
{ type: 'term', subType: 'variable', value: 'name' } |
http://x/p |
{ type: 'term', subType: 'namedNode', value: 'http://x/p' }; a parsed ex:p is { ..., value: 'p', prefix: 'ex' }
|
"lex" |
{ type: 'term', subType: 'literal', value: 'lex', langOrIri: undefined } |
"lex"@en |
{ ..., langOrIri: 'en' } |
"lex"^^http://dt |
{ ..., langOrIri: { type: 'term', subType: 'namedNode', value: 'http://dt' } } |
_:b0 |
{ type: 'term', subType: 'blankNode', label: 'b0' } |
* in a projection or COUNT(*)
|
{ type: 'wildcard' } |
Structure.
| SPARQL.js 2.x | Traqula |
|---|---|
{ type: 'query', queryType: 'SELECT' } |
{ type: 'query', subType: 'select', context: [], datasets, solutionModifiers: {}, where, variables } (context, datasets and solutionModifiers are required keys, possibly empty) |
variables: ['?x', { expression, variable }] |
variables: [ TermVariable, { type: 'pattern', subType: 'bind', expression, variable } ] or [ Wildcard ]
|
where: [ pattern, ... ] (array) |
where: { type: 'pattern', subType: 'group', patterns: [...] } (one group node) |
{ type: 'bgp', triples: [{ subject, predicate, object }] } |
{ type: 'pattern', subType: 'bgp', triples: [{ type: 'triple', subject, predicate, object }] } |
{ type: 'group' | 'optional' | 'minus', patterns } |
{ type: 'pattern', subType: ..., patterns } |
{ type: 'union', patterns: [ bgp, graph ] } |
{ type: 'pattern', subType: 'union', patterns: [ PatternGroup, PatternGroup ] }. Each arm must be a group. ldh:GraphUnionPattern puts a bgp and a graph directly under the union today; both get wrapped |
{ type: 'graph', name: '?g', patterns } |
{ type: 'pattern', subType: 'graph', name: TermVariable, patterns } |
{ type: 'filter', expression } |
{ type: 'pattern', subType: 'filter', expression } |
{ type: 'bind', variable: '?v', expression } |
{ type: 'pattern', subType: 'bind', variable: TermVariable, expression } |
{ type: 'values', values: [{ '?v': term }] } |
{ type: 'pattern', subType: 'values', variables: [ TermVariable ], values: [{ v: term }] } (bare key, plus the variables list) |
{ type: 'operation', operator: 'in' | '!' | 'isliteral' | 'regex', args } |
{ type: 'expression', subType: 'operation', operator, args: Expression[] }. The IN list is no longer a nested array of args; the exact shape is confirmed in the Phase 0 spike |
{ type: 'operation', operator: 'exists', args: [ group ] } |
{ type: 'expression', subType: 'patternOperation', operator: 'exists', args: PatternGroup } |
{ expression: '?x', type: 'aggregate', aggregation: 'count', distinct } |
{ type: 'expression', subType: 'aggregate', aggregation: 'count', distinct, expression: [ TermVariable ] | [ Wildcard ] } (one-element array) |
limit: 10, offset: 20 (top-level) |
solutionModifiers.limitOffset: { type: 'solutionModifier', subType: 'limitOffset', limit, offset } |
order: [{ expression, descending }] |
solutionModifiers.order: { type: 'solutionModifier', subType: 'order', orderDefs: [{ expression, descending: boolean }] } |
group: [{ expression }] |
solutionModifiers.group: { type: 'solutionModifier', subType: 'group', groupings: [ Expression ] } |
from: { default: [iri], named: [iri] } |
datasets: { type: 'datasetClauses', clauses: [{ clauseType: 'default' | 'named', value: TermIri }] } |
prefixes: { ex: 'http://...' } |
context: [{ type: 'contextDef', subType: 'prefix', key: 'ex', value: TermIriFull }] |
template: [ triple ] (CONSTRUCT) |
template: { type: 'pattern', subType: 'bgp', triples } |
{ type: 'path', pathType: '|', items } |
{ type: 'path', subType: '|', items } |
{ type: 'update', updates: [{ updateType: 'insertdelete', delete, insert, where }] } |
{ type: 'update', updates: [{ context: [], operation: { type: 'updateOperation', subType: 'modify', graph: undefined, insert: Quads[], delete: Quads[], from, where } }] } |
generator.stringify(tree) |
generator.generate(tree) |
new Parser() |
new Parser({ defaultContext: { astFactory: new AstFactory({ tracksSourceLocation: false }) } }), constructed once: Chevrotain pays its optimisation at construction |
| Area | What |
|---|---|
client/query-transforms.xsl |
19 modes, ~1100 lines: replace-variables, replace-limit, replace-offset, wrap-describe, strip-from, strip-order-by, link-predicates (with ldh:GraphUnionPattern), add-parallax-step, replace-order-by, toggle-desc, bgp-value-counts, filter-in, set-filter-in-values, result-count, container-select, add-constructor-triple, add-regex-filter, add-filter-in, append-values
|
| Term layer, shared SSR/CSR |
imports/default.xsl ldh:triples-to-descriptions (regex parsing of 2.x strings, to go); client/functions.xsl ldh:parse-query, ldh:parse-rdf-post, ldh:descriptions-to-triples, ldh:insertdelete-update; ldh:construct-instance, ldh:instance-term
|
| Server twin |
writer/function/ParseQuery.java (139 lines): emits the 2.x subset (template, where) behind ldh:parse-query under Saxon so constructors render identically on both products |
| Readers of keys outside the transforms |
client/modal.xsl (64 references), client/block/view.xsl (55, including ldh:alt-path-uris on pathType), client/functions.xsl (49), client/form.xsl (23), imports/default.xsl (22), client/constructor.xsl (15), client/map.xsl (6), client/block.xsl (5), client/block/chart.xsl (1). Keys: order, limit, offset, variables, descending, where, template, from, distinct
|
| Glue |
client.xsl $sparql-parser and $sparql-generator; layout.xsl $load-sparql-js and its script tag; js/SPARQL.js
|
| Siblings | REST-VKG / Web-Algebra: no consumer of the SPARQL.js JSON found; the ldh-* operations go through the platform's HTTP API |
-
Bundle. Traqula is ESM/CJS only, with no standalone browser build (parser 331 KB, rules 1.6 MB, core 0.8 MB unpacked; the minified bundle size is unknown and is measured here). Add
platform/traqula/with apackage.json, anindex.jsexporting{ Parser, Generator, AstFactory }ontowindow.traqula, and an esbuild invocation; commit the output asjs/traqula.jsthe waySPARQL.jswas committed, so the Maven/Docker build (COPY src) needs no Node step. Amake traqulatarget rebuilds it. -
Spike in Node, with the harness the project already uses for XSLT functions (a scratch stylesheet run by
npx xslt3-he, SaxonJS 3.0.0-beta2, the browser's engine): parse a view SELECT with the non-tracking factory,JSON.stringifyit, confirm everylocisautoGenerate; build a SELECT entirely by hand withautoGeneratelocs and generate it; confirm the generator accepts a root with noorigSource. Record the exact tree forFILTER(?x IN (...)),EXISTS,COUNT(DISTINCT *),VALUES,FROM, and a prefixed IRI with itscontext. -
Decide the IN shape and the union-arm wrapping from the spike output; both change what
filter-in,set-filter-in-values,add-filter-inandldh:GraphUnionPatternemit.
Exit criteria: the bundle loads from a <script> tag, parse and generate round-trip the spike queries, and the measured bundle size is recorded on this page.
Commit tests/xsl/query-transforms/: a Node runner that, per fixture, parses input.rq with the bundle, applies one mode with its parameters through xslt3-he (the stylesheet included from an entity-expanded copy, ac:uuid() stubbed), generates the result and diffs it against expected.rq. One fixture per mode, cut from the queries the tests/ui specs exercise (view facets, parallax, sort, pager, count, class list, search, geo, constructor), including prefixed queries so the prefix-resolution change is caught.
Written first, against SPARQL.js, so the expected strings are today's behaviour; each mode is then ported until its fixture is green again under Traqula. make xsl-tests runs it; CI runs it beside tests/http.
Exit criteria: every mode has a green fixture on SPARQL.js.
-
ldh:parse-query(CSR) returns the Traqula tree;ParseQuery.java(SSR) emits the same subset with Traqula term nodes (template.triples[]with term-node subjects, predicates and objects; thewherepresence flag unchanged). The Javadoc's lockstep note moves with it. -
ldh:triples-to-descriptionsreadssubType,value,langOrIriandlabelinstead of regex-splitting strings; the "quoting sucks in 2.x" TO-DO and the XMLLiteral regex go. -
ldh:descriptions-to-triplesandldh:parse-rdf-postemit term nodes;ldh:instance-termprefixeslabelrather than the_:string. - New
ldh:iri($term, $context)inimports/default.xsl(shared) resolvingprefixagainstcontext;ldh:alt-path-urisand every URI comparison use it.
Exit criteria: constructor forms render identically SSR and CSR (overlays/modal/constructor-editor.spec.mjs, forms/row-form.spec.mjs); the RDF/POST round trip is unchanged.
Port each mode against its Phase 1 fixture. Patterns that recur:
- matching
/json:map/json:number[@key = 'limit']becomes/json:map/json:map[@key = 'solutionModifiers']/json:map[@key = 'limitOffset']/json:number[@key = 'limit'], and absence means creating the modifier node, not just the key; -
json:array[@key = 'where']becomesjson:map[@key = 'where']/json:array[@key = 'patterns']; - every emitted node gets
<json:map key="loc"><json:string key="sourceLocationType">autoGenerate</json:string></json:map>, through one named templateldh:Locso it is written once; -
ldh:GraphUnionPatternwraps each arm in a group; - VALUES rows drop the
?and gain thevariableslist; - aggregates wrap
expressionin a one-element array.
Exit criteria: all fixtures green under Traqula.
-
client.xsl:$sparql-parserand$sparql-generatorconstructed fromwindow.traqulawith the non-tracking factory;stringifybecomesgenerateat the 11 serialise sites and inldh:insertdelete-update, whose tree becomes amodifyoperation. - Readers in
modal.xsl,block/view.xsl,form.xsl,constructor.xsl,map.xsl,block.xsl,block/chart.xslandnavigation.xsl: re-point the key paths (order,limit,offset,variables,descending,from,template) per the rename table. -
layout.xsl: the load flag loadsjs/traqula.jsand is renamed$load-sparql-parser. - Remove
js/SPARQL.js; README lists Traqula; CHANGELOG entry.
Exit criteria: make sef compiles; tests/ui green, in particular document/blocks/view/*, document/blocks/map, forms/combobox, overlays/modal/constructor-editor, shell/drawer/* and document/action-bar/create.
Switch the parser to the tracking factory and pass origSource to generate; mark only the nodes the XSLT changes as nodeReplace or autoGenerate. Untouched regions of an authored query then come back byte-identical, which removes the sibling-GRAPH merge and prefix-abbreviation concerns for good. Not needed for parity; listed so it is not forgotten.
-
Bundle size after minification is unknown until Phase 0: three packages plus
@traqula/chevrotain. If it is far aboveSPARQL.js's 73 KB, load it deferred as today and measure first paint. -
autoGenerateat the root withoutorigSource: the type comment says it "must have an ancestor of type nodeReplace". The non-tracking factory produces exactly this tree for every parse, so it has to work, but Phase 0 proves it. -
Prefix non-expansion is a semantic change relative to both 2.x and 3.x and bites every authored query with a PREFIX.
ldh:irimust be used at every comparison; the Phase 1 fixtures include prefixed queries to catch a missed one. -
Chevrotain parse speed on large authored queries: construct the parser once (already the case through the
client.xslparam) and measure in the spike. - The generator is single-threaded ("cannot generate multiple queries in parallel"): fine under SaxonJS's synchronous calls.
| Phase | Effort |
|---|---|
| 0 bundle and spike | 1 day |
| 1 harness and fixtures | 1.5 days |
| 2 term layer (XSL and Java) | 1.5 days |
| 3 transforms | 2.5 days |
| 4 call sites, glue, cleanup, UI run | 1.5 days |
| Total | ~8 days |