diff --git a/.eslintignore b/.eslintignore index 6168af4..8e61a52 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,5 @@ node_modules/ -dist coverage jest.config.js +schema-dts/lib/ +schema-dts/dist/ diff --git a/packages/schema-dts-gen/src/transform/toClass.ts b/packages/schema-dts-gen/src/transform/toClass.ts index f79df36..f07948a 100644 --- a/packages/schema-dts-gen/src/transform/toClass.ts +++ b/packages/schema-dts-gen/src/transform/toClass.ts @@ -18,9 +18,11 @@ import {Log} from '../logging/index.js'; import {ObjectPredicate, Topic, TypedTopic} from '../triples/triple.js'; import {UrlNode} from '../triples/types.js'; import { - IsNamedClass, + IsDirectlyNamedClass, IsDataType, ClassIsDataType, + IsNamedUrl, + IsSubclass, } from '../triples/wellKnown.js'; import { AliasBuiltin, @@ -29,7 +31,7 @@ import { DataTypeUnion, RoleBuiltin, } from '../ts/class.js'; -import {assert} from '../util/assert.js'; +import {assert, asserted, assertIs} from '../util/assert.js'; function toClass(cls: Class, topic: Topic, map: ClassMap): Class { const rest: ObjectPredicate[] = []; @@ -61,11 +63,21 @@ const wellKnownTypes = [ new AliasBuiltin('http://schema.org/Date', AliasBuiltin.Alias('string')), new AliasBuiltin('http://schema.org/DateTime', AliasBuiltin.Alias('string')), new AliasBuiltin('http://schema.org/Boolean', AliasBuiltin.Alias('boolean')), - new RoleBuiltin(UrlNode.Parse('http://schema.org/Role')), - new RoleBuiltin(UrlNode.Parse('http://schema.org/OrganizationRole')), - new RoleBuiltin(UrlNode.Parse('http://schema.org/EmployeeRole')), - new RoleBuiltin(UrlNode.Parse('http://schema.org/LinkRole')), - new RoleBuiltin(UrlNode.Parse('http://schema.org/PerformanceRole')), + new RoleBuiltin( + asserted(UrlNode.Parse('http://schema.org/Role'), IsNamedUrl) + ), + new RoleBuiltin( + asserted(UrlNode.Parse('http://schema.org/OrganizationRole'), IsNamedUrl) + ), + new RoleBuiltin( + asserted(UrlNode.Parse('http://schema.org/EmployeeRole'), IsNamedUrl) + ), + new RoleBuiltin( + asserted(UrlNode.Parse('http://schema.org/LinkRole'), IsNamedUrl) + ), + new RoleBuiltin( + asserted(UrlNode.Parse('http://schema.org/PerformanceRole'), IsNamedUrl) + ), ]; // Should we allow 'string' to be a valid type for all values of this type? @@ -90,7 +102,13 @@ function ForwardDeclareClasses(topics: readonly TypedTopic[]): ClassMap { if (IsDataType(topic.Subject)) { classes.set(topic.Subject.toString(), dataType); continue; - } else if (!IsNamedClass(topic)) continue; + } else if (!IsDirectlyNamedClass(topic) && !IsSubclass(topic)) continue; + + if (!IsNamedUrl(topic.Subject)) { + throw new Error( + `Unexpected unnamed URL ${topic.Subject.toString()} as a class.` + ); + } const wk = wellKnownTypes.find(wk => wk.subject.equivTo(topic.Subject)); if (ClassIsDataType(topic)) { @@ -108,6 +126,7 @@ function ForwardDeclareClasses(topics: readonly TypedTopic[]): ClassMap { wks.equivTo(topic.Subject) ); if (allowString) cls.addTypedef(AliasBuiltin.Alias('string')); + if (IsDirectlyNamedClass(topic)) cls.markAsExplicitClass(); classes.set(topic.Subject.toString(), cls); } @@ -117,12 +136,16 @@ function ForwardDeclareClasses(topics: readonly TypedTopic[]): ClassMap { function BuildClasses(topics: readonly TypedTopic[], classes: ClassMap) { for (const topic of topics) { - if (!IsNamedClass(topic)) continue; + if (!IsDirectlyNamedClass(topic) && !IsSubclass(topic)) continue; const cls = classes.get(topic.Subject.toString()); assert(cls); toClass(cls, topic, classes); } + + for (const cls of classes.values()) { + cls.validateClass(); + } } /** diff --git a/packages/schema-dts-gen/src/triples/reader.ts b/packages/schema-dts-gen/src/triples/reader.ts index 5ab3261..614c41c 100644 --- a/packages/schema-dts-gen/src/triples/reader.ts +++ b/packages/schema-dts-gen/src/triples/reader.ts @@ -53,7 +53,7 @@ function object(content: string) { } const totalRegex = - /\s*<([^<>]+)>\s*<([^<>]+)>\s*((?:<[^<>"]+>)|(?:"(?:[^"]|(?:\\"))+(?:[^\"]|\\")"(?:@[a-zA-Z]+)?))\s*\./; + /\s*<([^<>]+)>\s*<([^<>]+)>\s*((?:<[^<>"]+>)|(?:"(?:[^"]|(?:\\"))*(?:[^\"]|\\")"(?:@[a-zA-Z]+)?))\s*\./; export function toTripleStrings(data: string[]) { const linearTriples = data .join('') @@ -215,7 +215,9 @@ export function* process(triples: string[][]): Iterable { } catch (parseError) { const e = parseError as Error; throw new Error( - `ParseError: ${e.name}: ${e.message} while parsing line ${match}.\nOriginal Stack:\n${e.stack}\nRethrown from:` + `ParseError: ${e.name}: ${e.message} while parsing line ${match + .map(t => `\{${t}\}`) + .join(', ')}.\nOriginal Stack:\n${e.stack}\nRethrown from:` ); } } diff --git a/packages/schema-dts-gen/src/triples/triple.ts b/packages/schema-dts-gen/src/triples/triple.ts index 48fad48..5c2df9b 100644 --- a/packages/schema-dts-gen/src/triples/triple.ts +++ b/packages/schema-dts-gen/src/triples/triple.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {Rdfs, SchemaString, UrlNode} from './types.js'; +import {NamedUrlNode, Rdfs, SchemaString, UrlNode} from './types.js'; /** Represents a parsed Subject-Predicate-Object statement. */ export interface Triple { @@ -41,7 +41,7 @@ export interface ObjectPredicate { * A Node that can correspond to a "concept" in the ontology (class, property, * etc.). */ -export type TTypeName = UrlNode; +export type TTypeName = NamedUrlNode; /** A set of statements applying to the same Subject. */ export interface Topic { diff --git a/packages/schema-dts-gen/src/triples/types.ts b/packages/schema-dts-gen/src/triples/types.ts index 704efd2..271fcd1 100644 --- a/packages/schema-dts-gen/src/triples/types.ts +++ b/packages/schema-dts-gen/src/triples/types.ts @@ -24,8 +24,7 @@ export interface ReadonlyUrl { readonly path: readonly string[]; readonly search: string; } -function fromString(urlString: string): ReadonlyUrl { - const url = new URL(urlString); +function fromUrl(url: URL): ReadonlyUrl { return { href: url.href, protocol: url.protocol, @@ -34,6 +33,9 @@ function fromString(urlString: string): ReadonlyUrl { search: url.search, }; } +function fromString(urlString: string): ReadonlyUrl { + return fromUrl(new URL(urlString)); +} function pathEqual(first: readonly string[], second: readonly string[]) { if (first.length !== second.length) return false; for (let i = 0; i < first.length; ++i) { @@ -50,7 +52,7 @@ function pathEqual(first: readonly string[], second: readonly string[]) { export class UrlNode { readonly type = 'UrlNode'; constructor( - readonly name: string, + readonly name: string | undefined, readonly context: ReadonlyUrl, readonly href: string ) {} @@ -101,21 +103,25 @@ export class UrlNode { } if (url.search) { - throw new Error( - `Can't handle Search string in ${url.search} in ${url.href}` + // A URL with no hash but some "?..." search params + // should be treated the same as an unnamed URL. + return new UrlNode( + /*name=*/ undefined, + /*context=*/ fromUrl(url), + /*href=*/ url.href ); } const split = url.pathname.split('/'); - const name = split.pop(); - if (!name) { - throw new Error(`Unexpected URL ${url.href} with no room for 'name'.`); - } + let name = split.pop(); + if (name === '') name = undefined; + const context = url.origin + split.join('/'); return new UrlNode(name, fromString(context), url.href); } } +export type NamedUrlNode = UrlNode & {name: string}; /** * In-memory representation of a node in a Triple corresponding to a string diff --git a/packages/schema-dts-gen/src/triples/wellKnown.ts b/packages/schema-dts-gen/src/triples/wellKnown.ts index 5588dc5..d638d65 100644 --- a/packages/schema-dts-gen/src/triples/wellKnown.ts +++ b/packages/schema-dts-gen/src/triples/wellKnown.ts @@ -21,7 +21,7 @@ import { TTypeName, TypedTopic, } from './triple.js'; -import {UrlNode} from './types.js'; +import {NamedUrlNode, UrlNode} from './types.js'; /** Whether the context corresponds to rdf-schema. */ export function IsRdfSchema(value: UrlNode): boolean { @@ -43,6 +43,13 @@ export function IsRdfSyntax(value: UrlNode): boolean { export function IsSchemaObject(value: UrlNode): boolean { return value.context.hostname === 'schema.org'; } +/** Wheter the context corresponds to OWL */ +export function IsOWL(value: UrlNode): boolean { + return ( + value.context.hostname === 'www.w3.org' && + value.context.path[value.context.path.length - 1] === 'owl' + ); +} /** * If an ObjectPredicate represents a comment, returns the comment. Otherwise @@ -66,20 +73,35 @@ export function GetComment(value: ObjectPredicate): {comment: string} | null { */ export function GetSubClassOf( value: ObjectPredicate -): {subClassOf: TSubject} | null { +): {subClassOf: TTypeName} | null { if (IsRdfSchema(value.Predicate) && value.Predicate.name === 'subClassOf') { if (value.Object.type === 'SchemaString' || value.Object.type === 'Rdfs') { throw new Error( `Unexpected object for predicate 'subClassOf': ${value.Object}.` ); } + if (!IsNamedUrl(value.Object)) { + throw new Error( + `Unexpected "unnamed" URL used as a super-class: ${value.Object}` + ); + } return {subClassOf: value.Object}; } return null; } +/** Return true iff this object is a subclass of some other entity. */ +export function IsSubclass(topic: TypedTopic) { + return topic.values.some(op => GetSubClassOf(op) !== null); +} + +/** Returns true iff a UrlNode has a "name" it can be addressed with. */ +export function IsNamedUrl(t: UrlNode): t is NamedUrlNode { + return t.name !== undefined; +} + /** Returns true iff a node corresponds to http://schema.org/DataType */ -export function IsDataType(t: TTypeName): boolean { +export function IsDataType(t: TSubject): boolean { return IsSchemaObject(t) && t.name === 'DataType'; } @@ -89,8 +111,17 @@ export function ClassIsDataType(topic: TypedTopic): boolean { return false; } -/** Returns true iff a Topic represents a named class. */ -export function IsNamedClass(topic: TypedTopic): boolean { +/** + * Returns true iff a Topic represents a named class. + * + * Note that some schemas define subclasses without explicitly redefining them + * as classes. So just because a topic isn't directly named as a class doesn't + * mean that it isn't a named class. + * + * A named class is such if it *OR ANY OF ITS PARENTS* are directly named + * classes. + */ +export function IsDirectlyNamedClass(topic: TypedTopic): boolean { // Skip anything that isn't a class. return topic.types.some(IsClassType); } @@ -99,13 +130,19 @@ export function IsNamedClass(topic: TypedTopic): boolean { * Returns true iff a Predicate corresponds to http://schema.org/domainIncludes */ export function IsDomainIncludes(value: TPredicate): boolean { - return IsSchemaObject(value) && value.name === 'domainIncludes'; + return ( + (IsSchemaObject(value) && value.name === 'domainIncludes') || + (IsRdfSchema(value) && value.name === 'domain') + ); } /** * Returns true iff a Predicate corresponds to http://schema.org/rangeIncludes */ export function IsRangeIncludes(value: TPredicate): boolean { - return IsSchemaObject(value) && value.name === 'rangeIncludes'; + return ( + (IsSchemaObject(value) && value.name === 'rangeIncludes') || + (IsRdfSchema(value) && value.name === 'range') + ); } /** * Returns true iff a Predicate corresponds to http://schema.org/supersededBy. @@ -150,19 +187,9 @@ export function GetTypes( ): readonly TTypeName[] { const types = values.map(GetType).filter((t): t is TTypeName => !!t); - if (types.length === 0) { - throw new Error( - `No type found for Subject ${key.toString()}. Triples include:\n${values - .map( - v => - `${v.Predicate.toString()}: ${JSON.stringify( - v.Predicate - )}\n\t=> ${v.Object.toString()}` - ) - .join('\n')}` - ); - } - + // Allow empty types. Some custom schema assume "transitive" typing, e.g. + // gs1 has a TypeCode class which is an rdfs:Class, but its subclasses are + // not explicitly described as an rdfs:Class. return types; } @@ -170,7 +197,7 @@ export function GetTypes( * Returns true iff a Type corresponds to * http://www.w3.org/2000/01/rdf-schema#Class */ -export function IsClassType(type: TTypeName): boolean { +export function IsClassType(type: UrlNode): boolean { return IsRdfSchema(type) && type.name === 'Class'; } @@ -193,6 +220,25 @@ export function HasEnumType(types: readonly TTypeName[]): boolean { // Skip well-known types. if (IsClassType(type) || IsPropertyType(type) || IsDataType(type)) continue; + // Skip OWL "meta" types: + if (IsOWL(type)) { + if ( + [ + 'Ontology', + 'Class', + 'DatatypeProperty', + 'ObjectProperty', + 'FunctionalProperty', + 'InverseFunctionalProperty', + 'AnnotationProperty', + 'SymmetricProperty', + 'TransitiveProperty', + ].includes(type.name) + ) { + continue; + } + } + // If we're here, this is a 'Type' that is not well known. return true; } diff --git a/packages/schema-dts-gen/src/ts/class.ts b/packages/schema-dts-gen/src/ts/class.ts index 57316b0..30e3951 100644 --- a/packages/schema-dts-gen/src/ts/class.ts +++ b/packages/schema-dts-gen/src/ts/class.ts @@ -24,16 +24,14 @@ import type { const {factory, ModifierFlags, SyntaxKind} = ts; import {Log} from '../logging/index.js'; -import {TObject, TPredicate, TSubject} from '../triples/triple.js'; +import {TObject, TPredicate, TSubject, TTypeName} from '../triples/triple.js'; import {UrlNode} from '../triples/types.js'; import { GetComment, GetSubClassOf, IsSupersededBy, IsClassType, - IsDataType, - IsType, - IsTypeName, + IsNamedUrl, } from '../triples/wellKnown.js'; import {Context} from './context.js'; @@ -42,7 +40,7 @@ import {Property, TypeProperty} from './property.js'; import {arrayOf} from './util/arrayof.js'; import {appendLine, withComments} from './util/comments.js'; import {toClassName} from './util/names.js'; -import {assert} from '../util/assert.js'; +import {assert, asserted} from '../util/assert.js'; import {IdReferenceName} from './helper_types.js'; import {typeUnion} from './util/union.js'; @@ -64,6 +62,7 @@ export class Class { private _comment?: string; private _typedefs: TypeNode[] = []; private _isDataType = false; + private _explicitlyMarkedAsClass = false; private readonly children: Class[] = []; private readonly _parents: Class[] = []; private readonly _props: Set = new Set(); @@ -153,7 +152,7 @@ export class Class { return toClassName(this.subject); } - constructor(readonly subject: TSubject) {} + constructor(readonly subject: TTypeName) {} add( value: {Predicate: TPredicate; Object: TObject}, classMap: ClassMap @@ -207,6 +206,25 @@ export class Class { addTypedef(typedef: TypeNode) { this._typedefs.push(typedef); } + markAsExplicitClass() { + this._explicitlyMarkedAsClass = true; + } + private isMarkedAsClass(visited: WeakSet): boolean { + if (visited.has(this)) return false; + visited.add(this); + + return ( + this._explicitlyMarkedAsClass || + this._parents.some(p => p.isMarkedAsClass(visited)) + ); + } + validateClass(): void { + if (!this.isMarkedAsClass(new WeakSet())) { + throw new Error( + `Class ${this.className()} is not marked as an rdfs:Class, and neither are any of its parents.` + ); + } + } addProp(p: Property) { this._props.add(p); @@ -385,7 +403,12 @@ export class Class { /** * Represents a DataType. */ -export class Builtin extends Class {} +export class Builtin extends Class { + constructor(subject: TTypeName) { + super(subject); + this.markAsExplicitClass(); + } +} /** * A "Native" Schema.org object that is best represented @@ -393,7 +416,7 @@ export class Builtin extends Class {} */ export class AliasBuiltin extends Builtin { constructor(url: string, ...equivTo: TypeNode[]) { - super(UrlNode.Parse(url)); + super(asserted(UrlNode.Parse(url), IsNamedUrl)); for (const t of equivTo) this.addTypedef(t); } @@ -510,7 +533,7 @@ export class RoleBuiltin extends Builtin { export class DataTypeUnion extends Builtin { constructor(url: string, readonly wk: Builtin[]) { - super(UrlNode.Parse(url)); + super(asserted(UrlNode.Parse(url), IsNamedUrl)); } toNode(): DeclarationStatement[] { @@ -565,7 +588,7 @@ export function Sort(a: Class, b: Class): number { } function CompareKeys(a: TSubject, b: TSubject): number { - const byName = a.name.localeCompare(b.name); + const byName = (a.name || '').localeCompare(b.name || ''); if (byName !== 0) return byName; return a.href.localeCompare(b.href); diff --git a/packages/schema-dts-gen/src/ts/context.ts b/packages/schema-dts-gen/src/ts/context.ts index 4fa3bc2..fc3011c 100644 --- a/packages/schema-dts-gen/src/ts/context.ts +++ b/packages/schema-dts-gen/src/ts/context.ts @@ -57,7 +57,15 @@ export class Context { getScopedName(node: TSubject): string { for (const [name, url] of this.context) { if (node.matchesContext(url)) { - return name === '' ? node.name : `${name}:${node.name}`; + // Valid possibilities: + // - "schema:Foo" when name == schema && node.name == Foo. + // - "schema:" when name == schema && node.name is undefined. + // - "Foo" when name is empty and node.name is Foo. + // + // Don't allow "" when name is empty and node.name is undefined. + return name === '' + ? node.name ?? node.toString() + : `${name}:${node.name || ''}`; } } return node.toString(); diff --git a/packages/schema-dts-gen/src/ts/enum.ts b/packages/schema-dts-gen/src/ts/enum.ts index 5bd33af..34d3b10 100644 --- a/packages/schema-dts-gen/src/ts/enum.ts +++ b/packages/schema-dts-gen/src/ts/enum.ts @@ -18,11 +18,12 @@ import type {TypeNode} from 'typescript'; const {factory} = ts; import {Log} from '../logging/index.js'; -import {ObjectPredicate, TSubject, TTypeName} from '../triples/triple.js'; +import {ObjectPredicate, TSubject} from '../triples/triple.js'; import {GetComment, IsClassType, IsDataType} from '../triples/wellKnown.js'; import {ClassMap} from './class.js'; import {Context} from './context.js'; +import {UrlNode} from '../index.js'; /** * Corresponds to a value that belongs to an Enumeration. @@ -33,7 +34,7 @@ export class EnumValue { private comment?: string; constructor( readonly value: TSubject, - types: readonly TTypeName[], + types: readonly UrlNode[], map: ClassMap ) { for (const type of types) { diff --git a/packages/schema-dts-gen/src/util/assert.ts b/packages/schema-dts-gen/src/util/assert.ts index 7b9699a..a61f313 100644 --- a/packages/schema-dts-gen/src/util/assert.ts +++ b/packages/schema-dts-gen/src/util/assert.ts @@ -22,3 +22,20 @@ export function assert( ): asserts item is T { ok(item, message); } + +export function assertIs( + item: T, + assertion: (i: T) => i is U, + message?: string +): asserts item is U { + ok(assertion(item), message); +} + +export function asserted( + item: T, + assertion: (i: T) => i is U, + message?: string +): U { + assertIs(item, assertion, message); + return item; +} diff --git a/packages/schema-dts-gen/test/baselines/invalid_schemas_test.ts b/packages/schema-dts-gen/test/baselines/invalid_schemas_test.ts new file mode 100644 index 0000000..53ea6a6 --- /dev/null +++ b/packages/schema-dts-gen/test/baselines/invalid_schemas_test.ts @@ -0,0 +1,66 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @fileoverview Baseline tests are a set of tests (in tests/baseline/) that + * correspond to full comparisons of a generate .ts output based on a set of + * Triples representing an entire ontology. + */ +import {basename} from 'path'; + +import {inlineCli} from '../helpers/main_driver.js'; + +test(`invalidSyntax_${basename(import.meta.url)}`, async () => { + const run = inlineCli( + ` + <"INVALID> "X" . + `, + ['--ontology', `https://fake.com/${basename(import.meta.url)}.nt`] + ); + + await expect(run).rejects.toThrowError('ParseError'); +}); + +test(`unnamedURLClass_${basename(import.meta.url)}`, async () => { + const run = inlineCli( + ` + . + `, + ['--ontology', `https://fake.com/${basename(import.meta.url)}.nt`] + ); + + await expect(run).rejects.toThrowError('Unexpected unnamed URL'); +}); + +test(`notMarkedAsClass_cycle_${basename(import.meta.url)}`, async () => { + const run = inlineCli( + ` + . + . + . + "ABC" . + . + "ABC" . + . + . + . + "Data type: Text." . + `, + ['--ontology', `https://fake.com/${basename(import.meta.url)}.nt`] + ); + + await expect(run).rejects.toThrowError( + 'Thing is not marked as an rdfs:Class' + ); +}); diff --git a/packages/schema-dts-gen/test/baselines/owl_mixed_basic_test.ts b/packages/schema-dts-gen/test/baselines/owl_mixed_basic_test.ts new file mode 100644 index 0000000..e30ebb8 --- /dev/null +++ b/packages/schema-dts-gen/test/baselines/owl_mixed_basic_test.ts @@ -0,0 +1,203 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @fileoverview Baseline tests are a set of tests (in tests/baseline/) that + * correspond to full comparisons of a generate .ts output based on a set of + * Triples representing an entire ontology. + */ +import {basename} from 'path'; + +import {inlineCli} from '../helpers/main_driver.js'; + +test(`baseline_mixedOWL1_${basename(import.meta.url)}`, async () => { + const {actual} = await inlineCli( + ` + . + . + . + . + "ABC" . + . + . + "ABC" . + . + . + . + "Data type: Text." . + `, + ['--ontology', `https://fake.com/${basename(import.meta.url)}.nt`] + ); + + expect(actual).toMatchInlineSnapshot(` +"/** Used at the top-level node to indicate the context for the JSON-LD objects used. The context provided in this type is compatible with the keys and URLs in the rest of this generated file. */ +export type WithContext = T & { + \\"@context\\": \\"https://schema.org\\"; +}; +export interface Graph { + \\"@context\\": \\"https://schema.org\\"; + \\"@graph\\": readonly Thing[]; +} +type SchemaValue = T | readonly T[]; +type IdReference = { + /** IRI identifying the canonical address of this object. */ + \\"@id\\": string; +}; + +/** Data type: Text. */ +export type Text = string; + +interface PersonLeaf extends ThingBase { + \\"@type\\": \\"Person\\"; +} +/** ABC */ +export type Person = PersonLeaf | string; + +interface ThingBase extends Partial { + \\"name\\"?: SchemaValue; +} +interface ThingLeaf extends ThingBase { + \\"@type\\": \\"Thing\\"; +} +/** ABC */ +export type Thing = ThingLeaf | Person; + +" +`); +}); + +test(`baseline_mixedOWL2_${basename(import.meta.url)}`, async () => { + const {actual} = await inlineCli( + ` + . + . + . + . + "ABC" . + . + . + "ABC" . + . + . + . + . + "Data type: Text." . + `, + ['--ontology', `https://fake.com/${basename(import.meta.url)}.nt`] + ); + + expect(actual).toMatchInlineSnapshot(` +"/** Used at the top-level node to indicate the context for the JSON-LD objects used. The context provided in this type is compatible with the keys and URLs in the rest of this generated file. */ +export type WithContext = T & { + \\"@context\\": \\"https://schema.org\\"; +}; +export interface Graph { + \\"@context\\": \\"https://schema.org\\"; + \\"@graph\\": readonly Thing[]; +} +type SchemaValue = T | readonly T[]; +type IdReference = { + /** IRI identifying the canonical address of this object. */ + \\"@id\\": string; +}; + +/** Data type: Text. */ +export type Text = string; + +interface PersonLeaf extends ThingBase { + \\"@type\\": \\"Person\\"; +} +/** ABC */ +export type Person = PersonLeaf | string; + +interface ThingBase extends Partial { + \\"name\\"?: SchemaValue; +} +interface ThingLeaf extends ThingBase { + \\"@type\\": \\"Thing\\"; +} +/** ABC */ +export type Thing = ThingLeaf | Person; + +" +`); +}); + +test(`baseline_OWLenum_${basename(import.meta.url)}`, async () => { + const {actual} = await inlineCli( + ` + . + . + . + . + "ABC" . + . + . + "ABC" . + . + . + . + . + "Data type: Text." . + . + . + . + . + `, + ['--ontology', `https://fake.com/${basename(import.meta.url)}.nt`] + ); + + expect(actual).toMatchInlineSnapshot(` +"/** Used at the top-level node to indicate the context for the JSON-LD objects used. The context provided in this type is compatible with the keys and URLs in the rest of this generated file. */ +export type WithContext = T & { + \\"@context\\": \\"https://schema.org\\"; +}; +export interface Graph { + \\"@context\\": \\"https://schema.org\\"; + \\"@graph\\": readonly Thing[]; +} +type SchemaValue = T | readonly T[]; +type IdReference = { + /** IRI identifying the canonical address of this object. */ + \\"@id\\": string; +}; + +/** Data type: Text. */ +export type Text = string; + +interface MyEnumBase extends Partial { +} +interface MyEnumLeaf extends MyEnumBase { + \\"@type\\": \\"http://www.w3.org/2002/07/owl#MyEnum\\"; +} +export type MyEnum = \\"http://www.w3.org/2002/07/owl#EnumValueA\\" | \\"https://www.w3.org/2002/07/owl#EnumValueA\\" | \\"http://www.w3.org/2002/07/owl#EnumValueB\\" | \\"https://www.w3.org/2002/07/owl#EnumValueB\\" | MyEnumLeaf; + +interface PersonLeaf extends ThingBase { + \\"@type\\": \\"Person\\"; +} +/** ABC */ +export type Person = PersonLeaf | string; + +interface ThingBase extends Partial { + \\"name\\"?: SchemaValue; +} +interface ThingLeaf extends ThingBase { + \\"@type\\": \\"Thing\\"; +} +/** ABC */ +export type Thing = ThingLeaf | Person; + +" +`); +}); diff --git a/packages/schema-dts-gen/test/baselines/transitive_class_test.ts b/packages/schema-dts-gen/test/baselines/transitive_class_test.ts new file mode 100644 index 0000000..d737c46 --- /dev/null +++ b/packages/schema-dts-gen/test/baselines/transitive_class_test.ts @@ -0,0 +1,76 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @fileoverview Baseline tests are a set of tests (in tests/baseline/) that + * correspond to full comparisons of a generate .ts output based on a set of + * Triples representing an entire ontology. + */ +import {basename} from 'path'; + +import {inlineCli} from '../helpers/main_driver.js'; + +test(`baseline_${basename(import.meta.url)}`, async () => { + const {actual} = await inlineCli( + ` + . + . + . + "ABC" . + . + "ABC" . + . + . + . + "Data type: Text." . + `, + ['--ontology', `https://fake.com/${basename(import.meta.url)}.nt`] + ); + + expect(actual).toMatchInlineSnapshot(` +"/** Used at the top-level node to indicate the context for the JSON-LD objects used. The context provided in this type is compatible with the keys and URLs in the rest of this generated file. */ +export type WithContext = T & { + \\"@context\\": \\"https://schema.org\\"; +}; +export interface Graph { + \\"@context\\": \\"https://schema.org\\"; + \\"@graph\\": readonly Thing[]; +} +type SchemaValue = T | readonly T[]; +type IdReference = { + /** IRI identifying the canonical address of this object. */ + \\"@id\\": string; +}; + +/** Data type: Text. */ +export type Text = string; + +interface PersonLeaf extends ThingBase { + \\"@type\\": \\"Person\\"; +} +/** ABC */ +export type Person = PersonLeaf | string; + +interface ThingBase extends Partial { + \\"name\\"?: SchemaValue; +} +interface ThingLeaf extends ThingBase { + \\"@type\\": \\"Thing\\"; +} +/** ABC */ +export type Thing = ThingLeaf | Person; + +" +`); +}); diff --git a/packages/schema-dts-gen/test/helpers/make_class.ts b/packages/schema-dts-gen/test/helpers/make_class.ts index 56be2ef..4059028 100644 --- a/packages/schema-dts-gen/test/helpers/make_class.ts +++ b/packages/schema-dts-gen/test/helpers/make_class.ts @@ -13,11 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {UrlNode} from '../../src/triples/types.js'; +import {Property, PropertyType} from '../../src/index.js'; +import {NamedUrlNode, UrlNode} from '../../src/triples/types.js'; import {Class, ClassMap} from '../../src/ts/class.js'; export function makeClass(url: string): Class { - return new Class(UrlNode.Parse(url)); + return new Class(UrlNode.Parse(url) as NamedUrlNode); +} + +export function makeProperty(url: string): Property { + const u = UrlNode.Parse(url); + return new Property(u, new PropertyType(u)); } export function makeClassMap(...classes: Class[]): ClassMap { diff --git a/packages/schema-dts-gen/test/triples/reader_test.ts b/packages/schema-dts-gen/test/triples/reader_test.ts index b795043..f7c0b6b 100644 --- a/packages/schema-dts-gen/test/triples/reader_test.ts +++ b/packages/schema-dts-gen/test/triples/reader_test.ts @@ -54,7 +54,7 @@ describe('load', () => { get.mockReturnValueOnce(firstReturn); firstReturn.destroy(new Error('Bad!!!')); - await expect(triples$.toPromise()).rejects.toThrow('Bad!!!'); + await expect(firstValueFrom(triples$)).rejects.toThrow('Bad!!!'); expect(get).toBeCalledTimes(1); }); @@ -174,7 +174,7 @@ describe('load', () => { ]); }); - it('Multiple (throws from bad URL: Subject)', async () => { + it('Multiple (works with unnamed URL: subject)', async () => { const control = fakeResponse(200, 'Ok'); control.data( ` "math" .\n` @@ -184,39 +184,70 @@ describe('load', () => { ); control.end(); - await expect(triples).rejects.toThrow( - 'ParseError: Error: Unexpected URL' - ); + await expect(triples).resolves.toEqual([ + { + Subject: UrlNode.Parse('https://schema.org/Person'), + Predicate: UrlNode.Parse('https://schema.org/knowsAbout'), + Object: SchemaString.Parse('"math"')!, + }, + { + Subject: UrlNode.Parse('http://schema.org/'), + Predicate: UrlNode.Parse( + 'http://www.w3.org/2000/01/rdf-schema#comment' + ), + Object: SchemaString.Parse('"A test comment."')!, + }, + ]); }); - it('Multiple (throws from bad URL: Predicate)', async () => { + it('Multiple (works with search URL)', async () => { const control = fakeResponse(200, 'Ok'); control.data( ` "math" .\n` ); control.data( - ` "A test comment." .\n` + ` "A test comment." .\n` ); control.end(); - await expect(triples).rejects.toThrow( - 'ParseError: Error: Unexpected URL' - ); + await expect(triples).resolves.toEqual([ + { + Subject: UrlNode.Parse('https://schema.org/Person'), + Predicate: UrlNode.Parse('https://schema.org/knowsAbout'), + Object: SchemaString.Parse('"math"')!, + }, + { + Subject: UrlNode.Parse('https://schema.org/X?A=B'), + Predicate: UrlNode.Parse( + 'http://www.w3.org/2000/01/rdf-schema#comment' + ), + Object: SchemaString.Parse('"A test comment."')!, + }, + ]); }); - it('Multiple (throws from bad URL: Object)', async () => { + it('Multiple (works with unnamed URL: predicate)', async () => { const control = fakeResponse(200, 'Ok'); control.data( - ` .\n` + ` "math" .\n` ); control.data( - ` "A test comment." .\n` + ` "A test comment." .\n` ); control.end(); - await expect(triples).rejects.toThrow( - 'ParseError: Error: Unexpected URL' - ); + await expect(triples).resolves.toEqual([ + { + Subject: UrlNode.Parse('https://schema.org/Person'), + Predicate: UrlNode.Parse('https://schema.org/knowsAbout'), + Object: SchemaString.Parse('"math"')!, + }, + { + Subject: UrlNode.Parse('http://schema.org/A'), + Predicate: UrlNode.Parse('https://schema.org'), + Object: SchemaString.Parse('"A test comment."')!, + }, + ]); }); it('Multiple (dirty broken)', async () => { diff --git a/packages/schema-dts-gen/test/triples/types_test.ts b/packages/schema-dts-gen/test/triples/types_test.ts index 3eade69..d356903 100644 --- a/packages/schema-dts-gen/test/triples/types_test.ts +++ b/packages/schema-dts-gen/test/triples/types_test.ts @@ -47,35 +47,15 @@ describe('UrlNode', () => { expect(node.context.path).toEqual(['']); }); - it('rejects search strings', () => { - expect(() => - UrlNode.Parse('http://schema.org/Person?q=true&a') - ).toThrowError('Search string'); - - expect(() => UrlNode.Parse('http://schema.org/Person?q&a')).toThrowError( - 'Search string' - ); - - expect(() => UrlNode.Parse('http://schema.org/Person?q')).toThrowError( - 'Search string' - ); - - expect(() => - UrlNode.Parse('http://schema.org/abc?q#foo') - ).not.toThrowError(); - expect(() => UrlNode.Parse('http://schema.org/abc#?q')).not.toThrowError(); + it('treats search strings as unnamed', () => { + const node = UrlNode.Parse('http://schema.org/Person?q=true&a'); + expect(node.name).toBeUndefined(); + expect(node.context.href).toBe('http://schema.org/Person?q=true&a'); }); - it('top-level domain', () => { - expect(() => UrlNode.Parse('http://schema.org/')).toThrowError( - "no room for 'name'" - ); - - expect(() => UrlNode.Parse('http://schema.org')).toThrowError( - "no room for 'name'" - ); - - expect(() => UrlNode.Parse('http://schema.org/#foo')).not.toThrowError(); + it('treats top-level domain as unnamed', () => { + expect(UrlNode.Parse('http://schema.org/').name).toBeUndefined(); + expect(UrlNode.Parse('http://schema.org').name).toBeUndefined(); }); describe('matches context', () => { diff --git a/packages/schema-dts-gen/test/triples/wellKnown_test.ts b/packages/schema-dts-gen/test/triples/wellKnown_test.ts index 3810082..3e44dc0 100644 --- a/packages/schema-dts-gen/test/triples/wellKnown_test.ts +++ b/packages/schema-dts-gen/test/triples/wellKnown_test.ts @@ -13,13 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {Rdfs, SchemaString, UrlNode} from '../../src/triples/types.js'; +import { + NamedUrlNode, + Rdfs, + SchemaString, + UrlNode, +} from '../../src/triples/types.js'; import { GetComment, GetSubClassOf, GetType, GetTypes, - IsNamedClass, + IsDirectlyNamedClass, } from '../../src/triples/wellKnown.js'; describe('wellKnown', () => { @@ -123,6 +128,26 @@ describe('wellKnown', () => { }) ).toThrowError('Unexpected object for predicate'); }); + + it('only supports named UrlNodes as parents', () => { + expect(() => + GetSubClassOf({ + Predicate: UrlNode.Parse( + 'http://www.w3.org/2000/01/rdf-schema#subClassOf' + ), + Object: UrlNode.Parse('https://schema.org/'), + }) + ).toThrowError('Unexpected "unnamed" URL used as a super-class'); + + expect(() => + GetSubClassOf({ + Predicate: UrlNode.Parse( + 'http://www.w3.org/2000/01/rdf-schema#subClassOf' + ), + Object: UrlNode.Parse('https://schema.org'), + }) + ).toThrowError('Unexpected "unnamed" URL used as a super-class'); + }); }); describe('GetType', () => { @@ -234,39 +259,20 @@ describe('wellKnown', () => { UrlNode.Parse('http://schema.org/Thing'), ]); }); - - it('Throws if none', () => { - expect(() => - GetTypes(UrlNode.Parse('https://schema.org/Widget'), []) - ).toThrowError('No type found'); - - expect(() => - GetTypes(UrlNode.Parse('https://schema.org/Widget'), [ - { - Predicate: UrlNode.Parse( - 'http://www.w3.org/1999/02/22-rdf-syntax-ns#property' - ), - Object: UrlNode.Parse('http://www.w3.org/2000/01/rdf-schema#Class'), - }, - { - Predicate: UrlNode.Parse( - 'http://www.w3.org/2000/01/rdf-schema#label' - ), - Object: new SchemaString('Thing', undefined), - }, - ]) - ).toThrowError('No type found'); - }); }); - describe('IsNamedClass', () => { - const cls = UrlNode.Parse('http://www.w3.org/2000/01/rdf-schema#Class'); - const dataType = UrlNode.Parse('http://schema.org/DataType'); - const bool = UrlNode.Parse('http://schema.org/Boolean'); + describe('IsDirectlyNamedClass', () => { + const cls = UrlNode.Parse( + 'http://www.w3.org/2000/01/rdf-schema#Class' + ) as NamedUrlNode; + const dataType = UrlNode.Parse( + 'http://schema.org/DataType' + ) as NamedUrlNode; + const bool = UrlNode.Parse('http://schema.org/Boolean') as NamedUrlNode; it('a data type is a named class', () => { expect( - IsNamedClass({ + IsDirectlyNamedClass({ Subject: UrlNode.Parse('https://schema.org/Text'), types: [cls, dataType], values: [], @@ -274,7 +280,7 @@ describe('wellKnown', () => { ).toBe(true); expect( - IsNamedClass({ + IsDirectlyNamedClass({ Subject: UrlNode.Parse('https://schema.org/Text'), types: [dataType, cls], values: [], @@ -284,7 +290,7 @@ describe('wellKnown', () => { it('an only-enum is not a class', () => { expect( - IsNamedClass({ + IsDirectlyNamedClass({ Subject: UrlNode.Parse('https://schema.org/True'), types: [bool], values: [], @@ -294,7 +300,7 @@ describe('wellKnown', () => { it('an enum can still be a class', () => { expect( - IsNamedClass({ + IsDirectlyNamedClass({ Subject: UrlNode.Parse('https://schema.org/ItsComplicated'), types: [bool, cls], values: [], @@ -304,7 +310,7 @@ describe('wellKnown', () => { it('the DataType union is a class', () => { expect( - IsNamedClass({ + IsDirectlyNamedClass({ Subject: UrlNode.Parse('https://schema.org/DataType'), types: [cls], values: [ diff --git a/packages/schema-dts-gen/test/ts/class_test.ts b/packages/schema-dts-gen/test/ts/class_test.ts index 83a3eec..21c82d3 100644 --- a/packages/schema-dts-gen/test/ts/class_test.ts +++ b/packages/schema-dts-gen/test/ts/class_test.ts @@ -16,7 +16,7 @@ import ts from 'typescript'; -import {SchemaString, UrlNode} from '../../src/triples/types.js'; +import {NamedUrlNode, SchemaString, UrlNode} from '../../src/triples/types.js'; import { AliasBuiltin, Class, @@ -25,7 +25,7 @@ import { Builtin, } from '../../src/ts/class.js'; import {Context} from '../../src/ts/context.js'; -import {makeClass, makeClassMap} from '../helpers/make_class.js'; +import {makeClass, makeClassMap, makeProperty} from '../helpers/make_class.js'; describe('Class', () => { let cls: Class; @@ -235,6 +235,36 @@ describe('Class', () => { ).toThrowError('unknown node type'); }); }); + + describe('property sorting', () => { + const ctx = new Context(); + ctx.addNamedContext('schema', 'https://schema.org/'); + + it('alphabetic, respecting empty', () => { + const cls = makeClass('https://schema.org/A'); + cls.addProp(makeProperty('https://schema.org/a')); + cls.addProp(makeProperty('https://schema.org/b')); + cls.addProp(makeProperty('https://schema.org/')); + cls.addProp(makeProperty('https://schema.org/c')); + cls.addProp(makeProperty('https://abc.com/e')); + cls.addProp(makeProperty('https://abc.com')); + + expect(asString(cls, ctx)).toMatchInlineSnapshot(` +"interface ABase extends Partial { + \\"https://abc.com/\\"?: SchemaValue; + \\"schema:\\"?: SchemaValue; + \\"schema:a\\"?: SchemaValue; + \\"schema:b\\"?: SchemaValue; + \\"schema:c\\"?: SchemaValue; + \\"https://abc.com/e\\"?: SchemaValue; +} +interface ALeaf extends ABase { + \\"@type\\": \\"schema:A\\"; +} +export type A = ALeaf;" +`); + }); + }); }); describe('Sort(Class, Class)', () => { @@ -362,7 +392,7 @@ describe('Sort(Class, Class)', () => { // Can be same as less specific builtins. expect( Sort( - new Builtin(UrlNode.Parse('https://schema.org/Boo')), + new Builtin(UrlNode.Parse('https://schema.org/Boo') as NamedUrlNode), new AliasBuiltin('https://schema.org/Boo', AliasBuiltin.Alias('Text')) ) ).toBe(0); diff --git a/packages/schema-dts-gen/test/ts/context_test.ts b/packages/schema-dts-gen/test/ts/context_test.ts index e7d9048..31ba8f9 100644 --- a/packages/schema-dts-gen/test/ts/context_test.ts +++ b/packages/schema-dts-gen/test/ts/context_test.ts @@ -143,6 +143,13 @@ describe('Context.getScopedName', () => { expect(ctx.getScopedName(UrlNode.Parse('https://foo.org/Door'))).toBe( 'https://foo.org/Door' ); + + expect(ctx.getScopedName(UrlNode.Parse('https://schema.org/'))).toBe( + 'https://schema.org/' + ); + expect(ctx.getScopedName(UrlNode.Parse('https://schema.org'))).toBe( + 'https://schema.org/' + ); }); it('with single domain URL (http)', () => { @@ -185,6 +192,12 @@ describe('Context.getScopedName', () => { expect(ctx.getScopedName(UrlNode.Parse('http://foo.org/Door'))).toBe( 'http://foo.org/Door' ); + expect(ctx.getScopedName(UrlNode.Parse('http://schema.org/'))).toBe( + 'schema:' + ); + expect(ctx.getScopedName(UrlNode.Parse('http://schema.org'))).toBe( + 'schema:' + ); }); }); diff --git a/packages/schema-dts-gen/test/ts/names_test.ts b/packages/schema-dts-gen/test/ts/names_test.ts index 5ae1dc7..3e5452b 100644 --- a/packages/schema-dts-gen/test/ts/names_test.ts +++ b/packages/schema-dts-gen/test/ts/names_test.ts @@ -13,43 +13,43 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {UrlNode} from '../../src/triples/types.js'; +import {NamedUrlNode, UrlNode} from '../../src/triples/types.js'; import {toClassName} from '../../src/ts/util/names.js'; +function parseNamed(url: string): NamedUrlNode { + return UrlNode.Parse(url) as NamedUrlNode; +} + describe('toClassName', () => { it('operates normally, with typical inputs', () => { - expect(toClassName(UrlNode.Parse('https://schema.org/Person'))).toBe( - 'Person' - ); - expect(toClassName(UrlNode.Parse('https://schema.org/Person3'))).toBe( + expect(toClassName(parseNamed('https://schema.org/Person'))).toBe('Person'); + expect(toClassName(parseNamed('https://schema.org/Person3'))).toBe( 'Person3' ); - expect(toClassName(UrlNode.Parse('http://schema.org/Person'))).toBe( - 'Person' - ); + expect(toClassName(parseNamed('http://schema.org/Person'))).toBe('Person'); expect( - toClassName(UrlNode.Parse('http://schema.org/Organization4Organization')) + toClassName(parseNamed('http://schema.org/Organization4Organization')) ).toBe('Organization4Organization'); }); it('handles illegal TypeScript identifier characters', () => { - expect(toClassName(UrlNode.Parse('https://schema.org/Person-4'))).toBe( + expect(toClassName(parseNamed('https://schema.org/Person-4'))).toBe( 'Person_4' ); - expect(toClassName(UrlNode.Parse('https://schema.org/Person%4'))).toBe( + expect(toClassName(parseNamed('https://schema.org/Person%4'))).toBe( 'Person_4' ); - expect(toClassName(UrlNode.Parse('https://schema.org/Person%204'))).toBe( + expect(toClassName(parseNamed('https://schema.org/Person%204'))).toBe( 'Person_4' ); - expect(toClassName(UrlNode.Parse('https://schema.org/Person, 4'))).toBe( + expect(toClassName(parseNamed('https://schema.org/Person, 4'))).toBe( 'Person__4' ); - expect(toClassName(UrlNode.Parse('https://schema.org/3DModel'))).toBe( + expect(toClassName(parseNamed('https://schema.org/3DModel'))).toBe( '_3DModel' ); - expect(toClassName(UrlNode.Parse('https://schema.org/3DModel-5'))).toBe( + expect(toClassName(parseNamed('https://schema.org/3DModel-5'))).toBe( '_3DModel_5' ); });