Files
ng-helpers/src/sparql.ts
T

86 lines
3.4 KiB
TypeScript

/**
* The SPARQL this layer writes — one statement, and one use for it: the field an
* index declares, written on the index document's own subject at creation.
*
* There is NO delete builder, and there must not be one: an index never removes
* an entry, and the surest way to keep that true is for this package to be unable
* to express a removal at all.
*
* `@ng-eventually/polyfill` has escaping helpers of its own but does NOT publish
* them — `contract_polyfill-surface` lists no `escapeIri` / `escapeLiteral` — so
* this package carries its own rather than reaching into that package's internals.
*
* Pure string builders, on purpose: their exact output is asserted in the unit
* tests, and they are executed against a broker only through `polyfillPort`.
*/
/** Characters that would close an IRI written between angle brackets. */
const IRI_DELIMITERS = new Set(['"', "<", ">", "\\", "^", "`", "{", "|", "}"]);
/** True when the character may not appear inside `<...>`: a control, a space, or a delimiter. */
function breaksIri(character: string): boolean {
const code = character.codePointAt(0);
if (code === undefined) return false;
return code <= 0x20 || code === 0x7f || IRI_DELIMITERS.has(character);
}
/** True when the whole string can be written between angle brackets as-is. */
export function isIriSafe(value: string): boolean {
for (const character of value) {
if (breaksIri(character)) return false;
}
return true;
}
/** Neutralises anything that could close a SPARQL string literal. */
export function escapeLiteral(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
/**
* Percent-encodes anything that could close an IRI written between angle
* brackets. Printable non-ASCII is left alone — it is legal in an IRI, and
* encoding it would corrupt it.
*/
export function escapeIri(value: string): string {
let out = "";
for (const character of value) {
if (!breaksIri(character)) {
out += character;
continue;
}
const code = character.codePointAt(0) ?? 0;
out += `%${code.toString(16).toUpperCase().padStart(2, "0")}`;
}
return out;
}
/**
* One triple, for the ANCHORED default graph. The only statement this package
* ever writes, and it writes it once per index.
*
* No `GRAPH <…>` wrapper, and the document is not a parameter at all: it is named
* once, as `docs.sparqlUpdate`'s anchor, which already scopes the write to that
* repo's default graph. Naming it twice would be two chances to disagree.
*
* This is the polyfill's own shape, and the alignment is deliberate: it calls the
* no-GRAPH form "the CANONICAL, always-safe shape and the one the anchored
* default-graph read queries" (`src/surface/inbox.ts`). The anchored
* `GRAPH <plainNuri>` form this package wrote until now ALSO round-trips — the
* polyfill's own e2e harness verified it resolves to the same repo graph, with no
* phantom graph — so this is not a bug being fixed. It is two layers writing the
* same kind of data the same way, instead of leaving someone to work out later
* whether the difference meant something.
*/
export function buildInsertTriple(subject: string, predicate: string, value: string): string {
return (
`INSERT DATA { <${escapeIri(subject)}> <${escapeIri(predicate)}> ` +
`"${escapeLiteral(value)}" }`
);
}