Skip to content

Commit 360ed03

Browse files
author
Niels V
committed
Add sparql-tag and term modules, inlined from sparql-client-2
These two modules are extracted from node-sparql-client so that sparql-client-2 can be removed as a dependency without losing the tagged-template-literal SPARQL builder and the RDF term types it depended on.
1 parent e7a2705 commit 360ed03

2 files changed

Lines changed: 248 additions & 0 deletions

File tree

helpers/mu/sparql-tag.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Term from './term.js';
2+
3+
/**
4+
* ECMAScript 2015 tagged template function for building safe SPARQL queries.
5+
*
6+
* Interpolated values are converted to their SPARQL representation using
7+
* Term.create so they are properly escaped and typed.
8+
*
9+
* @example
10+
* const name = "O'Brien";
11+
* const query = SPARQL`SELECT * WHERE { ?s foaf:name ${name} }`;
12+
* // name becomes """O'Brien"""
13+
*/
14+
export default function SPARQL(template, ...substitutions) {
15+
let result = template[0];
16+
substitutions.forEach((value, i) => {
17+
result += Term.create(value).format() + template[i + 1];
18+
});
19+
return result;
20+
}

helpers/mu/term.js

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* RDF Term types for use in SPARQL template literal interpolation.
3+
*
4+
* This software incorporates code derived from node-sparql-client
5+
* (https://github.com/eddieantonio/node-sparql-client), MIT License.
6+
*
7+
* Adapted from the original: merged into a single ESM file to eliminate
8+
* circular module dependencies, and modernised to ES2015 class syntax.
9+
*/
10+
11+
// ── Term (abstract base) ─────────────────────────────────────────────────────
12+
13+
class Term {
14+
format() {
15+
throw new Error("term MUST implement a #format method!");
16+
}
17+
}
18+
19+
// ── IRI ──────────────────────────────────────────────────────────────────────
20+
21+
class IRI extends Term {
22+
/**
23+
* Creates an IRI from a string or a single-key object {prefix: localname}.
24+
*/
25+
static create(value) {
26+
if (typeof value === "string") return new IRIReference(value);
27+
if (typeof value === "object" && value !== null) return IRI.createFromObject(value);
28+
throw new TypeError("Invalid IRI: expected string or object, got " + typeof value);
29+
}
30+
31+
static createFromObject(object) {
32+
const keys = Object.keys(object);
33+
if (keys.length !== 1) throw new Error("Invalid prefixed IRI: object must have exactly one key.");
34+
const namespace = keys[0];
35+
const local = object[namespace];
36+
if (typeof local !== "string") throw new TypeError("Invalid prefixed IRI: local name must be a string.");
37+
if (!/^[^\s;.,<|$]+$/.test(local)) throw new Error("Invalid IRI identifier: " + local);
38+
return new PrefixedNameIRI(namespace, local);
39+
}
40+
}
41+
42+
class PrefixedNameIRI extends IRI {
43+
constructor(namespace, id) {
44+
super();
45+
this.namespace = namespace;
46+
this.id = id;
47+
}
48+
49+
format() {
50+
return this.namespace + ":" + this.id;
51+
}
52+
}
53+
54+
class IRIReference extends IRI {
55+
constructor(iri) {
56+
super();
57+
/* Reject characters forbidden in IRIREF per SPARQL 1.1 spec:
58+
* < > " { } | ^ backtick backslash and codepoints 0x00-0x20 */
59+
if (/[<>"{}|^`\\]/.test(iri) || [...iri].some(ch => ch.codePointAt(0) <= 0x20)) {
60+
throw new Error("Invalid IRI: " + iri);
61+
}
62+
this.iri = iri;
63+
}
64+
65+
format() {
66+
return "<" + this.iri + ">";
67+
}
68+
}
69+
70+
// ── Literal ──────────────────────────────────────────────────────────────────
71+
72+
const SPARQL_LITERAL_PATTERNS = {
73+
boolean: /^true$|^false$/,
74+
integer: /^[-+]?[0-9]+$/,
75+
double: /^[-+]?(?:[0-9]+\.[0-9]*|\.[0-9]+|[0-9]+)[eE][+-]?[0-9]+$/,
76+
decimal: /^[-+]?[0-9]*\.[0-9]+$/,
77+
};
78+
79+
class Literal extends Term {
80+
constructor(value, datatype) {
81+
super();
82+
this.value = "" + value;
83+
if (datatype !== undefined) {
84+
this.datatype = IRI.create(datatype);
85+
}
86+
}
87+
88+
static create(value) {
89+
return new StringLiteral(value);
90+
}
91+
92+
static createWithLanguageTag(value, languageTag) {
93+
if (typeof languageTag !== "string") {
94+
throw new TypeError("Language tag must be a string.");
95+
}
96+
return new StringLiteral(value, languageTag);
97+
}
98+
99+
/** @deprecated Use createWithLanguageTag (fixes typo in original API name). */
100+
static createWithLangaugeTag(value, languageTag) {
101+
return Literal.createWithLanguageTag(value, languageTag);
102+
}
103+
104+
static createWithDataType(value, datatype) {
105+
if (datatype === undefined) throw new TypeError("Undefined datatype provided.");
106+
return new Literal(value, datatype);
107+
}
108+
109+
format() {
110+
if (isKnownXsdDatatype(this.datatype)) {
111+
const term = tryFormatXsdType(this.value, this.datatype.id);
112+
if (term !== undefined) {
113+
return term.wrapAsString
114+
? formatStringWithDataType(term.literal, this.datatype)
115+
: term.literal;
116+
}
117+
}
118+
return formatStringWithDataType(this.value, this.datatype);
119+
}
120+
}
121+
122+
class StringLiteral extends Literal {
123+
constructor(value, languageTag) {
124+
super(value);
125+
if (languageTag !== undefined) {
126+
if (!/^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/.test(languageTag)) {
127+
throw new Error("Invalid language tag: " + languageTag);
128+
}
129+
this.languageTag = languageTag;
130+
}
131+
}
132+
133+
format() {
134+
const str = formatRDFString(this.value);
135+
return this.languageTag !== undefined ? str + "@" + this.languageTag : str;
136+
}
137+
}
138+
139+
function isKnownXsdDatatype(iri) {
140+
return iri != null && iri.namespace === "xsd" && iri.id in SPARQL_LITERAL_PATTERNS;
141+
}
142+
143+
function tryFormatXsdType(value, type) {
144+
const stringified = "" + value;
145+
if (type === "double") {
146+
if (Math.abs(+value) === Infinity) {
147+
return { literal: (value < 0 ? "-" : "") + "INF", wrapAsString: true };
148+
}
149+
if (SPARQL_LITERAL_PATTERNS.double.test(stringified)) return { literal: stringified };
150+
const withExponent = stringified + "e0";
151+
if (SPARQL_LITERAL_PATTERNS.double.test(withExponent)) return { literal: withExponent };
152+
return undefined;
153+
}
154+
if (SPARQL_LITERAL_PATTERNS[type].test(stringified)) return { literal: stringified };
155+
}
156+
157+
/**
158+
* Formats a string value as a SPARQL RDF literal.
159+
* Uses triple double-quotes to support newlines and most special characters;
160+
* only backslash and embedded triple-quote sequences need escaping.
161+
*/
162+
function formatRDFString(value) {
163+
const str = "" + value;
164+
const escaped = str
165+
.replace(/\\/g, "\\\\")
166+
.replace(/"""/g, '""\\"');
167+
return '"""' + escaped + '"""';
168+
}
169+
170+
function formatStringWithDataType(value, datatype) {
171+
const str = formatRDFString(value);
172+
return datatype !== undefined ? str + "^^" + datatype.format() : str;
173+
}
174+
175+
// ── Term.create ───────────────────────────────────────────────────────────────
176+
177+
const KNOWN_XSD_DATATYPES = { boolean: 1, decimal: 1, double: 1, integer: 1 };
178+
179+
Term.create = function create(value, options) {
180+
if (options) return createTerm(Object.assign({}, options, { value }));
181+
return createTerm(value);
182+
};
183+
184+
function createTerm(value) {
185+
const rawValue = value == null ? value : value.valueOf();
186+
if (rawValue === null || rawValue === undefined) {
187+
throw new TypeError("Cannot bind null or undefined value");
188+
}
189+
const type = typeof rawValue;
190+
switch (type) {
191+
case "string": return Literal.create(rawValue);
192+
case "number": return Literal.createWithDataType(rawValue, { xsd: "double" });
193+
case "boolean": return Literal.createWithDataType(rawValue, { xsd: "boolean" });
194+
case "object": return createTermFromObject(rawValue);
195+
}
196+
throw new TypeError("Cannot bind " + type + " value: " + value);
197+
}
198+
199+
function createTermFromObject(object) {
200+
if (Object.keys(object).length === 1) return IRI.createFromObject(object);
201+
202+
const { value } = object;
203+
if (value === undefined) {
204+
throw new Error(
205+
"Binding must contain a `value` property. " +
206+
"To bind a URI, write { value: 'http://...', type: 'uri' }."
207+
);
208+
}
209+
210+
resolveDataTypeShortcuts(object);
211+
212+
if (object.type === "uri") return IRI.create(value);
213+
if (object.lang !== undefined) return Literal.createWithLanguageTag(value, object.lang);
214+
if (object["xml:lang"] !== undefined) return Literal.createWithLanguageTag(value, object["xml:lang"]);
215+
if (object.datatype !== undefined) return Literal.createWithDataType(value, object.datatype);
216+
217+
throw new Error("Could not bind object: " + JSON.stringify(object));
218+
}
219+
220+
function resolveDataTypeShortcuts(object) {
221+
const TERM_TYPES = { bnode: 1, literal: 1, uri: 1 };
222+
const { type } = object;
223+
if (type === undefined || type in TERM_TYPES) return;
224+
object.datatype = type in KNOWN_XSD_DATATYPES ? { xsd: type } : type;
225+
object.type = "literal";
226+
}
227+
228+
export default Term;

0 commit comments

Comments
 (0)