Skip to content

Commit 1e112c8

Browse files
feat(prez-lib): add Jena Lucene SHACL search parser mode (#261)
* feat(prez-lib): add Jena Lucene SHACL search parser mode * add tests
1 parent d108095 commit 1e112c8

13 files changed

Lines changed: 836 additions & 29 deletions

docs/2026-04-15-jena-lucene-shacl-search-design.md

Lines changed: 411 additions & 0 deletions
Large diffs are not rendered by default.

packages/prez-lib/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,19 @@ const store = new RDFStore();
3232
store.load(data);
3333
const data = store.getItem();
3434
```
35+
36+
## Search parser modes
37+
38+
`search()` defaults to the existing flat Prez search result shape.
39+
40+
The parser mode is strict: the mode you configure must match the backend search model. `prez-lib` does not do compatibility fallbacks between the flat and Jena Lucene SHACL shapes.
41+
42+
To opt into Jena Lucene SHACL nested matches, pass the parser mode explicitly:
43+
44+
```typescript
45+
import { search } from "prez-lib";
46+
47+
const results = await search("https://example.com", "/search?q=rock", {
48+
parserMode: "jena-lucene-shacl",
49+
});
50+
```

packages/prez-lib/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"scripts": {
3030
"dev": "vite",
3131
"build": "tsc && vite build",
32+
"test": "node --test tests/**/*.test.mjs",
3233
"types": "tsc",
3334
"preview": "vite preview",
3435
"preinstall": "npx only-allow pnpm"

packages/prez-lib/src/consts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const PREZ_PREDICATES = {
3838
searchResultPredicate: "https://prez.dev/searchResultPredicate",
3939
searchResultURI: "https://prez.dev/searchResultURI",
4040
searchResultMatch: "https://prez.dev/searchResultMatch",
41+
hasSearchMatch: "https://prez.dev/hasSearchMatch",
4142
hasChildren: "https://prez.dev/hasChildren",
4243
facetCount: "https://prez.dev/facetCount",
4344
facetName: "https://prez.dev/facetName",

packages/prez-lib/src/service.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
1-
import type { PrezBlankNode, PrezDataItem, PrezDataList, PrezDataSearch, PrezNodeList, PrezProfileHeader, PrezProfiles, PrezProperties, PrezProperty } from "./types";
1+
import type {
2+
PrezBlankNode,
3+
PrezDataItem,
4+
PrezDataList,
5+
PrezDataSearch,
6+
PrezDataSearchDefault,
7+
PrezDataSearchLuceneShacl,
8+
PrezNodeList,
9+
PrezProfileHeader,
10+
PrezProfiles,
11+
PrezProperties,
12+
PrezProperty,
13+
PrezSearchOptions,
14+
PrezSearchOptionsDefault,
15+
PrezSearchOptionsLuceneShacl
16+
} from "./types";
217
import { RDFStore } from "./store";
318
import { SYSTEM_PREDICATES } from "./consts";
419

@@ -150,19 +165,37 @@ export async function getItem(baseUrl: string, path: string): Promise<PrezDataIt
150165
* @param path Search path along with any query parameters
151166
* @returns
152167
*/
153-
export async function search(baseUrl: string, path: string): Promise<PrezDataSearch> {
168+
export async function search(baseUrl: string, path: string, options?: PrezSearchOptionsDefault): Promise<PrezDataSearchDefault>;
169+
export async function search(baseUrl: string, path: string, options: PrezSearchOptionsLuceneShacl): Promise<PrezDataSearchLuceneShacl>;
170+
export async function search(baseUrl: string, path: string, options: PrezSearchOptions = {}): Promise<PrezDataSearch> {
154171
const url = baseUrl + path;
155-
const pathOnly = new URL(url).pathname
172+
const pathOnly = new URL(url).pathname;
156173
const { data, profiles } = await apiGet(url);
157174
const store = new RDFStore();
158175
store.setBaseUrl(baseUrl);
159176
store.load(data);
177+
const parserMode = options.parserMode ?? "default";
178+
179+
if (parserMode === "jena-lucene-shacl") {
180+
return {
181+
type: 'search',
182+
parserMode,
183+
data: store.search({ parserMode }),
184+
profiles,
185+
maxReached: store.getMaxReached(),
186+
count: store.getCount(),
187+
parents: store.getParents(pathOnly),
188+
facets: store.getFacets()
189+
};
190+
}
191+
160192
return {
161-
type: 'search',
162-
data: store.search(),
163-
profiles,
164-
maxReached: store.getMaxReached(),
165-
count: store.getCount(),
193+
type: 'search',
194+
parserMode: "default",
195+
data: store.search(),
196+
profiles,
197+
maxReached: store.getMaxReached(),
198+
count: store.getCount(),
166199
parents: store.getParents(pathOnly),
167200
facets: store.getFacets()
168201
};

packages/prez-lib/src/store.ts

Lines changed: 132 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
import { Store, Parser, DataFactory, type Quad_Object, type Quad_Subject, type Term, type Quad } from "n3";
2-
import type { PrezLiteral, PrezNode, PrezTerm, PrezProperties, PrezSearchResult, PrezFocusNode, PrezLink, PrezConceptSchemeNode, PrezConceptNode, PrezOntologyNode, PrezConceptSchemeOntologyNode, PrezBBlockNode, PrezLinkParent, PrezNodeList, PrezFacet } from "./types";
2+
import type {
3+
PrezAnySearchResult,
4+
PrezBBlockNode,
5+
PrezConceptNode,
6+
PrezConceptSchemeNode,
7+
PrezConceptSchemeOntologyNode,
8+
PrezFacet,
9+
PrezFlatSearchResult,
10+
PrezFocusNode,
11+
PrezLink,
12+
PrezLinkParent,
13+
PrezLiteral,
14+
PrezLuceneShaclSearchResult,
15+
PrezNode,
16+
PrezNodeList,
17+
PrezOntologyNode,
18+
PrezProperties,
19+
PrezSearchMatch,
20+
PrezSearchOptions,
21+
PrezSearchOptionsDefault,
22+
PrezSearchOptionsLuceneShacl,
23+
PrezTerm
24+
} from "./types";
325
import { DEFAULT_PREFIXES, PREZ_PREDICATES, SYSTEM_PREDICATES, BBLOCK_TYPES } from "./consts";
426
import { defaultToIri, defaultFromIri } from "./helpers";
527
import { node, literal, bnode } from "./factory";
@@ -163,6 +185,29 @@ export class RDFStore {
163185
return this.getObjects(iri, predicate).map(o => this.toPrezTerm(o) as PrezLiteral)[0] || undefined;
164186
}
165187

188+
private toSubjectTerm(subject: string | Quad_Subject | Quad_Object): Quad_Subject {
189+
if (typeof subject === "string") {
190+
return namedNode(subject);
191+
}
192+
193+
if (subject.termType === "Literal") {
194+
throw new Error(`Cannot use literal ${subject.value} as a subject.`);
195+
}
196+
197+
return subject;
198+
}
199+
200+
private getRequiredObject(subject: string | Quad_Subject | Quad_Object, predicate: string, description: string): Quad_Object {
201+
const object = this.getObjects(subject, predicate)[0];
202+
203+
if (!object) {
204+
const subjectValue = typeof subject === "string" ? subject : subject.value;
205+
throw new Error(`Missing ${description} for ${subjectValue}.`);
206+
}
207+
208+
return object;
209+
}
210+
166211
public getProperties(term: Term, options?: {excludePrefix?: string, includePrefix?: string}): PrezProperties {
167212
const props: PrezProperties = {};
168213

@@ -326,13 +371,14 @@ export class RDFStore {
326371
* @param predicate a string or string array of predicate IRIs
327372
* @returns the array of objects
328373
*/
329-
public getObjects(subject: string, predicate: string | string[]): Quad_Object[] {
374+
public getObjects(subject: string | Quad_Subject | Quad_Object, predicate: string | string[]): Quad_Object[] {
375+
const subjectTerm = this.toSubjectTerm(subject);
330376
if (typeof predicate === "string") {
331-
return this.store.getObjects(namedNode(subject), namedNode(predicate), null);
377+
return this.store.getObjects(subjectTerm, namedNode(predicate), null);
332378
} else {
333379
const objs: Quad_Object[] = [];
334380
predicate.forEach(p => {
335-
objs.push(...this.store.getObjects(namedNode(subject), namedNode(p), null));
381+
objs.push(...this.store.getObjects(subjectTerm, namedNode(p), null));
336382
});
337383
return objs;
338384
}
@@ -628,19 +674,89 @@ export class RDFStore {
628674
/**
629675
* Returns search results
630676
*/
631-
public search(): PrezSearchResult[] {
677+
private extractSearchResultHash(subject: Quad_Subject): string {
678+
return subject.value.startsWith("urn:hash:") ? subject.value.slice("urn:hash:".length) : subject.value;
679+
}
680+
681+
private getSearchResultBase(subject: Quad_Subject) {
682+
const weightObject = this.getRequiredObject(subject, PREZ_PREDICATES.searchResultWeight, "search result weight");
683+
const resourceObject = this.getRequiredObject(subject, PREZ_PREDICATES.searchResultURI, "search result URI");
684+
685+
return {
686+
hash: this.extractSearchResultHash(subject),
687+
weight: Number(weightObject.value),
688+
resource: this.toPrezFocusNode(resourceObject)
689+
};
690+
}
691+
692+
private getOptionalFlatSearchMatch(subject: Quad_Subject): PrezSearchMatch | undefined {
693+
const predicateObject = this.getObjects(subject, PREZ_PREDICATES.searchResultPredicate)[0];
694+
const matchObject = this.getObjects(subject, PREZ_PREDICATES.searchResultMatch)[0];
695+
696+
if (!predicateObject && !matchObject) {
697+
return undefined;
698+
}
699+
700+
if (!predicateObject || !matchObject) {
701+
throw new Error(`Incomplete flat search match for ${subject.value}.`);
702+
}
703+
704+
return {
705+
predicate: this.toPrezTerm(predicateObject) as PrezNode,
706+
match: this.toPrezTerm(matchObject) as PrezLiteral
707+
};
708+
}
709+
710+
private parseSearchMatch(subject: Quad_Object): PrezSearchMatch {
711+
const predicateObject = this.getRequiredObject(subject, PREZ_PREDICATES.searchResultPredicate, "search match predicate");
712+
const matchObject = this.getRequiredObject(subject, PREZ_PREDICATES.searchResultMatch, "search match literal");
713+
714+
return {
715+
predicate: this.toPrezTerm(predicateObject) as PrezNode,
716+
match: this.toPrezTerm(matchObject) as PrezLiteral
717+
};
718+
}
719+
720+
private parseDefaultSearchResult(subject: Quad_Subject): PrezFlatSearchResult {
721+
if (this.getObjects(subject, PREZ_PREDICATES.hasSearchMatch).length > 0) {
722+
throw new Error(`Received Lucene SHACL search matches for ${subject.value} while parserMode is default.`);
723+
}
724+
725+
const flatMatch = this.getOptionalFlatSearchMatch(subject);
726+
727+
if (!flatMatch) {
728+
throw new Error(`Missing flat search match data for ${subject.value}.`);
729+
}
730+
731+
return {
732+
...this.getSearchResultBase(subject),
733+
...flatMatch
734+
};
735+
}
736+
737+
private parseJenaLuceneShaclSearchResult(subject: Quad_Subject): PrezLuceneShaclSearchResult {
738+
const nestedMatchNodes = this.getObjects(subject, PREZ_PREDICATES.hasSearchMatch);
739+
if (this.getOptionalFlatSearchMatch(subject)) {
740+
throw new Error(`Received flat search match fields for ${subject.value} while parserMode is jena-lucene-shacl.`);
741+
}
742+
743+
return {
744+
...this.getSearchResultBase(subject),
745+
matches: nestedMatchNodes.map(matchNode => this.parseSearchMatch(matchNode))
746+
};
747+
}
748+
749+
public search(options?: PrezSearchOptionsDefault): PrezFlatSearchResult[];
750+
public search(options: PrezSearchOptionsLuceneShacl): PrezLuceneShaclSearchResult[];
751+
public search(options: PrezSearchOptions = {}): PrezAnySearchResult[] {
752+
const parserMode = options.parserMode ?? "default";
632753
const resultSubjects = this.getSubjects(SYSTEM_PREDICATES.a, PREZ_PREDICATES.searchResult);
633-
const results: PrezSearchResult[] = resultSubjects.map(s => {
634-
const result: PrezSearchResult = {
635-
hash: s.value.split("urn:hash:").slice(-1)[0]!,
636-
weight: Number(this.getObjects(s.value, PREZ_PREDICATES.searchResultWeight)[0]!.value),
637-
predicate: this.toPrezTerm(this.getObjects(s.value, PREZ_PREDICATES.searchResultPredicate)[0]!) as PrezNode,
638-
match: this.toPrezTerm(this.getObjects(s.value, PREZ_PREDICATES.searchResultMatch)[0]!) as PrezLiteral,
639-
resource: this.toPrezFocusNode(this.getObjects(s.value, PREZ_PREDICATES.searchResultURI)[0]!)
640-
};
641-
return result;
642-
});
643-
return results;
754+
755+
if (parserMode === "jena-lucene-shacl") {
756+
return resultSubjects.map(subject => this.parseJenaLuceneShaclSearchResult(subject));
757+
}
758+
759+
return resultSubjects.map(subject => this.parseDefaultSearchResult(subject));
644760
}
645761

646762

packages/prez-lib/src/types.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,17 +211,57 @@ export interface PrezProperties {
211211
[predicateIri: string]: PrezProperty;
212212
};
213213

214+
export type PrezSearchParserMode = "default" | "jena-lucene-shacl";
215+
216+
export type PrezSearchOptionsDefault = {
217+
parserMode?: "default";
218+
};
219+
220+
export type PrezSearchOptionsLuceneShacl = {
221+
parserMode: "jena-lucene-shacl";
222+
};
223+
224+
export type PrezSearchOptions = PrezSearchOptionsDefault | PrezSearchOptionsLuceneShacl;
225+
214226
/**
215-
* Represents a search result
227+
* Represents a single predicate/snippet pair inside a Lucene SHACL search result.
216228
*/
217-
export type PrezSearchResult = {
229+
export type PrezSearchMatch = {
230+
predicate: PrezNode;
231+
match: PrezLiteral;
232+
};
233+
234+
/**
235+
* Represents the default flat Prez search result shape.
236+
*/
237+
export type PrezFlatSearchResult = {
218238
hash: string;
219239
weight: number;
220240
predicate: PrezNode;
221241
match: PrezLiteral;
222242
resource: PrezFocusNode;
223243
};
224244

245+
/**
246+
* Backwards-compatible alias for the default flat search result shape.
247+
*/
248+
export type PrezSearchResult = PrezFlatSearchResult;
249+
250+
/**
251+
* Represents the Jena Lucene SHACL search result shape.
252+
*/
253+
export type PrezLuceneShaclSearchResult = {
254+
hash: string;
255+
weight: number;
256+
resource: PrezFocusNode;
257+
matches: PrezSearchMatch[];
258+
};
259+
260+
/**
261+
* Represents any supported Prez search result shape.
262+
*/
263+
export type PrezAnySearchResult = PrezFlatSearchResult | PrezLuceneShaclSearchResult;
264+
225265
/**
226266
* Represents an item in Prez that contains node info and its related properties
227267
*/
@@ -270,7 +310,7 @@ export type PrezDataTypes = 'item' | 'list' | 'search';
270310

271311
export interface PrezData {
272312
type: PrezDataTypes;
273-
data: PrezFocusNode | PrezFocusNode[] | PrezSearchResult[];
313+
data: PrezFocusNode | PrezFocusNode[] | PrezAnySearchResult[];
274314
profiles: PrezProfileHeader[];
275315
parents: PrezLinkParent[];
276316
facets: PrezFacet[];
@@ -289,13 +329,25 @@ export interface PrezDataItem extends PrezData {
289329
store: RDFStore;
290330
}
291331

292-
export interface PrezDataSearch extends PrezData {
332+
export interface PrezDataSearchBase extends Omit<PrezData, "type" | "data"> {
293333
type: 'search';
334+
parserMode: PrezSearchParserMode;
294335
count: number;
295-
data: PrezSearchResult[];
296336
maxReached: boolean;
297337
}
298338

339+
export interface PrezDataSearchDefault extends PrezDataSearchBase {
340+
parserMode: "default";
341+
data: PrezFlatSearchResult[];
342+
}
343+
344+
export interface PrezDataSearchLuceneShacl extends PrezDataSearchBase {
345+
parserMode: "jena-lucene-shacl";
346+
data: PrezLuceneShaclSearchResult[];
347+
}
348+
349+
export type PrezDataSearch = PrezDataSearchDefault | PrezDataSearchLuceneShacl;
350+
299351
export type PrezFacetValue = {
300352
term: (PrezLiteral | PrezNode);
301353
count: number;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
PREFIX prez: <https://prez.dev/>
2+
PREFIX ex: <https://example.com/>
3+
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
4+
5+
<urn:hash:flat-1> a prez:SearchResult ;
6+
prez:searchResultWeight "2.5"^^xsd:float ;
7+
prez:searchResultPredicate ex:title ;
8+
prez:searchResultMatch "Flat result match" ;
9+
prez:searchResultURI ex:item-1 .
10+
11+
ex:item-1 a ex:Thing ;
12+
prez:label "Flat Item" ;
13+
prez:description "Flat item description" .
14+
15+
ex:title prez:label "Title" .
16+
ex:Thing prez:label "Thing" .

0 commit comments

Comments
 (0)