From 3ab42e809f728e20829de1a388696c49d0b84b43 Mon Sep 17 00:00:00 2001 From: Mathew Benjamin <90913413+mathewtbenjamin@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:29:15 +1000 Subject: [PATCH] fix: support rdf:Description typed-node syntax in RDF import Importing the Brick ontology (and any ontology serialized by rdflib or similar tools) failed with 'No ontology metadata or OWL classes found'. The parser only recognized typed node elements (), but many serializers emit the equivalent rdf:Description form: Brick 1.4's official RDF/XML declares all 1,530 classes this way and contains zero typed elements. - Add getTypedElements(): collects both typed node elements and rdf:Description elements typed via rdf:type, used for owl:Ontology, owl:Class, owl:DatatypeProperty and owl:ObjectProperty extraction. - Detect Turtle input (@prefix/@base) and raise a clear, actionable error instead of 'Malformed XML' (brickschema.org defaults to .ttl). - Replace the live getElementsByTagName('*') collection in the DataBinding scan with a static querySelectorAll('*') snapshot; repeated indexed access on live collections is quadratic in some DOM implementations, making large imports pathologically slow. - 9 new tests: rdf:Description extraction for all four types, mixed syntax dedupe, and Turtle detection. Verified against Brick 1.4 RDF/XML (5.3 MB): parses to 1,530 entity types, ontology name 'Brick'. Fixes #85 --- src/lib/rdf/parser.test.ts | 95 ++++++++++++++++++++++++++++++++++++++ src/lib/rdf/parser.ts | 60 +++++++++++++++++++++--- 2 files changed, 149 insertions(+), 6 deletions(-) diff --git a/src/lib/rdf/parser.test.ts b/src/lib/rdf/parser.test.ts index 0e5ee846..3a7a8249 100644 --- a/src/lib/rdf/parser.test.ts +++ b/src/lib/rdf/parser.test.ts @@ -407,4 +407,99 @@ describe('parseRDF', () => { expect(ontology.entityTypes[0].name).toBe('Widget'); }); }); + + describe('rdf:Description typed-node syntax (#85)', () => { + // Serializers like Python's rdflib (used by Brick and many published + // ontologies) declare resources as with an rdf:type + // child instead of typed elements like . + const descriptionRdf = ` + + + + + Description Ontology + Serialized via rdf:Description + + + + + Building + A building + + + + + Sensor + + + + + name + + + + + + + hasSensor + + + + +`; + + it('extracts ontology metadata from rdf:Description elements', () => { + const { ontology } = parseRDF(descriptionRdf); + expect(ontology.name).toBe('Description Ontology'); + expect(ontology.description).toBe('Serialized via rdf:Description'); + }); + + it('extracts classes declared via rdf:type', () => { + const { ontology } = parseRDF(descriptionRdf); + expect(ontology.entityTypes).toHaveLength(2); + const names = ontology.entityTypes.map((e) => e.name); + expect(names).toContain('Building'); + expect(names).toContain('Sensor'); + }); + + it('extracts datatype properties declared via rdf:type', () => { + const { ontology } = parseRDF(descriptionRdf); + const building = ontology.entityTypes.find((e) => e.name === 'Building'); + expect(building?.properties).toHaveLength(1); + expect(building?.properties[0]).toMatchObject({ name: 'name', type: 'string' }); + }); + + it('extracts object properties declared via rdf:type', () => { + const { ontology } = parseRDF(descriptionRdf); + expect(ontology.relationships).toHaveLength(1); + expect(ontology.relationships[0]).toMatchObject({ from: 'building', to: 'sensor' }); + }); + + it('does not duplicate entities when typed elements and descriptions coexist', () => { + const mixed = descriptionRdf.replace( + '', + ` + Building + ` + ); + const { ontology } = parseRDF(mixed); + expect(ontology.entityTypes).toHaveLength(2); + }); + }); + + describe('Turtle input detection (#85)', () => { + it('gives an actionable error for Turtle content', () => { + const ttl = `@prefix brick: .\n@prefix owl: .\n\nbrick:Building a owl:Class .`; + expect(() => parseRDF(ttl)).toThrow(RDFParseError); + expect(() => parseRDF(ttl)).toThrow(/Turtle/); + }); + + it('still reports malformed XML for non-Turtle garbage', () => { + expect(() => parseRDF('this is not xml at all')).toThrow(RDFParseError); + expect(() => parseRDF('this is not xml at all')).toThrow(/Malformed XML|No ontology metadata/); + }); + }); }); diff --git a/src/lib/rdf/parser.ts b/src/lib/rdf/parser.ts index 350b86da..f0ecd071 100644 --- a/src/lib/rdf/parser.ts +++ b/src/lib/rdf/parser.ts @@ -125,6 +125,41 @@ const RDF_NS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; const RDFS_NS = 'http://www.w3.org/2000/01/rdf-schema#'; const OWL_NS = 'http://www.w3.org/2002/07/owl#'; +/** + * Collect all elements declaring a resource of the given type, supporting both + * RDF/XML syntaxes: + * + * 1. Typed node elements: `` + * 2. rdf:Description elements: `` + * ` ` + * + * Many serializers (e.g. Python's rdflib, used by Brick and other published + * ontologies) emit only the second form, which we previously ignored (#85). + */ +function getTypedElements(root: Element, namespace: string, localName: string): Element[] { + const results: Element[] = Array.from(root.getElementsByTagNameNS(namespace, localName)); + const typeUri = namespace + localName; + + // Snapshot the live collection: indexed access on live collections is + // expensive in some DOM implementations (quadratic in jsdom). + const descEls = Array.from(root.getElementsByTagNameNS(RDF_NS, 'Description')); + for (let i = 0; i < descEls.length; i++) { + const el = descEls[i]; + for (let j = 0; j < el.children.length; j++) { + const child = el.children[j]; + const childLocal = child.localName || child.tagName.split(':').pop(); + if (childLocal !== 'type') continue; + const resource = + child.getAttribute('rdf:resource') || child.getAttributeNS(RDF_NS, 'resource'); + if (resource === typeUri) { + results.push(el); + break; + } + } + } + return results; +} + interface ParsedDatatypeProperty { about: string; label: string; @@ -143,6 +178,15 @@ interface ParsedDatatypeProperty { * Parse an RDF/XML (OWL) string into an Ontology and optional DataBindings. */ export function parseRDF(rdfXml: string): { ontology: Ontology; bindings: DataBinding[] } { + // Give Turtle input a clear, actionable error instead of an XML parse error. + const sniff = rdfXml.trimStart(); + if (!sniff.startsWith('<') && /^(@prefix|@base|PREFIX\s|BASE\s)/m.test(sniff)) { + throw new RDFParseError( + 'This file appears to be Turtle (.ttl), which is not supported yet. ' + + 'Please convert it to RDF/XML first (for example with Apache Jena "riot" or an online RDF converter).' + ); + } + const parser = new DOMParser(); const doc = parser.parseFromString(rdfXml, 'application/xml'); @@ -158,7 +202,7 @@ export function parseRDF(rdfXml: string): { ontology: Ontology; bindings: DataBi let ontologyName = ''; let ontologyDescription = ''; - const ontologyEls = root.getElementsByTagNameNS(OWL_NS, 'Ontology'); + const ontologyEls = getTypedElements(root, OWL_NS, 'Ontology'); if (ontologyEls.length > 0) { const ontEl = ontologyEls[0]; ontologyName = getChildText(ontEl, 'label', RDFS_NS) || ''; @@ -166,7 +210,7 @@ export function parseRDF(rdfXml: string): { ontology: Ontology; bindings: DataBi } // --- Extract OWL Classes → EntityTypes --- - const classEls = root.getElementsByTagNameNS(OWL_NS, 'Class'); + const classEls = getTypedElements(root, OWL_NS, 'Class'); const entityMap = new Map(); for (let i = 0; i < classEls.length; i++) { @@ -192,7 +236,7 @@ export function parseRDF(rdfXml: string): { ontology: Ontology; bindings: DataBi } // --- Extract DatatypeProperties → Properties + Relationship Attributes --- - const dtPropEls = root.getElementsByTagNameNS(OWL_NS, 'DatatypeProperty'); + const dtPropEls = getTypedElements(root, OWL_NS, 'DatatypeProperty'); const parsedDtProps: ParsedDatatypeProperty[] = []; for (let i = 0; i < dtPropEls.length; i++) { @@ -268,7 +312,7 @@ export function parseRDF(rdfXml: string): { ontology: Ontology; bindings: DataBi } // --- Extract ObjectProperties → Relationships --- - const objPropEls = root.getElementsByTagNameNS(OWL_NS, 'ObjectProperty'); + const objPropEls = getTypedElements(root, OWL_NS, 'ObjectProperty'); const relationships: Relationship[] = []; for (let i = 0; i < objPropEls.length; i++) { @@ -323,8 +367,12 @@ export function parseRDF(rdfXml: string): { ontology: Ontology; bindings: DataBi // --- Extract DataBindings --- const bindings: DataBinding[] = []; - // Look for ont:DataBinding elements (they use the ontology namespace) - const allElements = root.getElementsByTagName('*'); + // Look for ont:DataBinding elements (they use the ontology namespace). + // Use a static snapshot instead of the live getElementsByTagName('*') + // collection: repeated indexed access on live collections is quadratic in + // some DOM implementations, which made importing large ontologies + // (e.g. Brick, ~500k elements) pathologically slow. + const allElements = root.querySelectorAll('*'); for (let i = 0; i < allElements.length; i++) { const el = allElements[i]; const localName = el.localName || el.tagName.split(':').pop();