diff --git a/.gitignore b/.gitignore index 774bdbd58..d0176d04e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,5 @@ rust-executor/CUSTOM_DENO_SNAPSHOT.bin rust-executor/test_data .npmrc +docs-src/ +.worktrees/ diff --git a/Cargo.lock b/Cargo.lock index eeb5118fb..5d212d43b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,7 @@ dependencies = [ "tokio-rustls 0.26.4", "tokio-stream", "url", + "urlencoding", "uuid 1.18.1", "warp", "webbrowser", diff --git a/SHACL_SDNA_ARCHITECTURE.md b/SHACL_SDNA_ARCHITECTURE.md new file mode 100644 index 000000000..2d71a7ce9 --- /dev/null +++ b/SHACL_SDNA_ARCHITECTURE.md @@ -0,0 +1,255 @@ +# SHACL SDNA Architecture + +**Replacing Prolog-based Subject DNA with W3C SHACL + AD4M Extensions** + +## Overview + +AD4M uses Subject DNA (SDNA) to define data schemas and their operational behavior. This document describes the migration from Prolog-based SDNA to a SHACL-based approach that: + +1. **W3C Conformant** - Schema definitions use standard SHACL predicates +2. **Extensible** - AD4M-specific actions use a separate `ad4m://` namespace +3. **Queryable** - All metadata stored as links, queryable via SurrealQL or simple link queries +4. **Consistent** - Same pattern for Classes and Flows + +--- + +## Architecture: Separate Links Approach + +### Design Principles + +1. **One predicate = one action type** - Clear semantics +2. **SHACL for schema, AD4M for behavior** - Clean separation +3. **Named URIs** - Property shapes have queryable URIs (not blank nodes) +4. **JSON in literals** - Actions stored as JSON arrays in `literal://string:` format + +### Link Structure + +For a class named `Recipe` with namespace `recipe://`: + +| Link Type | Source | Predicate | Target | +| ------------- | ----------------------- | -------------------- | ------------------------ | +| Shape Type | `recipe://RecipeShape` | `rdf://type` | `sh://NodeShape` | +| Target Class | `recipe://RecipeShape` | `sh://targetClass` | `recipe://Recipe` | +| Has Property | `recipe://RecipeShape` | `sh://property` | `recipe://Recipe.name` | +| Property Type | `recipe://Recipe.name` | `rdf://type` | `sh://PropertyShape` | +| Property Path | `recipe://Recipe.name` | `sh://path` | `recipe://name` | +| Datatype | `recipe://Recipe.name` | `sh://datatype` | `xsd://string` | +| Constructor | `recipe://RecipeShape` | `ad4m://constructor` | `literal://string:[...]` | +| Destructor | `recipe://RecipeShape` | `ad4m://destructor` | `literal://string:[...]` | +| Setter | `recipe://Recipe.name` | `ad4m://setter` | `literal://string:[...]` | +| Adder | `recipe://Recipe.items` | `ad4m://adder` | `literal://string:[...]` | +| Remover | `recipe://Recipe.items` | `ad4m://remover` | `literal://string:[...]` | + +--- + +## Example: Complete Recipe Class + +### TypeScript Definition + +```typescript +@ModelOptions({ + name: "Recipe", + namespace: "recipe://", +}) +class Recipe { + @SubjectProperty({ through: "recipe://name", writable: true }) + name: string = ""; + + @SubjectProperty({ through: "recipe://rating", writable: true }) + rating: number = 0; + + @SubjectCollection({ through: "recipe://has_ingredient" }) + ingredients: string[] = []; +} +``` + +### W3C SHACL Links (Schema) + +```turtle +# Shape definition +recipe://RecipeShape rdf:type sh:NodeShape . +recipe://RecipeShape sh:targetClass recipe://Recipe . + +# Property: name +recipe://Recipe.name rdf:type sh:PropertyShape . +recipe://Recipe.name sh:path recipe://name . +recipe://Recipe.name sh:datatype xsd:string . +recipe://Recipe.name sh:maxCount 1 . +recipe://RecipeShape sh:property recipe://Recipe.name . + +# Property: rating +recipe://Recipe.rating rdf:type sh:PropertyShape . +recipe://Recipe.rating sh:path recipe://rating . +recipe://Recipe.rating sh:datatype xsd:integer . +recipe://Recipe.rating sh:maxCount 1 . +recipe://RecipeShape sh:property recipe://Recipe.rating . + +# Collection: ingredients +recipe://Recipe.ingredients rdf:type ad4m:CollectionShape . +recipe://Recipe.ingredients sh:path recipe://has_ingredient . +recipe://Recipe.ingredients sh:nodeKind sh:IRI . +recipe://RecipeShape sh:property recipe://Recipe.ingredients . +``` + +### AD4M Action Links (Behavior) + +```turtle +# Constructor - creates instance with default values +recipe://RecipeShape ad4m://constructor """literal://string:[ + {"action": "addLink", "source": "this", "predicate": "recipe://name", "target": ""}, + {"action": "addLink", "source": "this", "predicate": "recipe://rating", "target": "0"} +]""" . + +# Destructor - removes instance links +recipe://RecipeShape ad4m://destructor """literal://string:[ + {"action": "removeLink", "source": "this", "predicate": "recipe://name"}, + {"action": "removeLink", "source": "this", "predicate": "recipe://rating"} +]""" . + +# Property setters +recipe://Recipe.name ad4m://setter """literal://string:[ + {"action": "setSingleTarget", "source": "this", "predicate": "recipe://name", "target": "value"} +]""" . + +recipe://Recipe.rating ad4m://setter """literal://string:[ + {"action": "setSingleTarget", "source": "this", "predicate": "recipe://rating", "target": "value"} +]""" . + +# Collection operations +recipe://Recipe.ingredients ad4m://adder """literal://string:[ + {"action": "addLink", "source": "this", "predicate": "recipe://has_ingredient", "target": "value"} +]""" . + +recipe://Recipe.ingredients ad4m://remover """literal://string:[ + {"action": "removeLink", "source": "this", "predicate": "recipe://has_ingredient", "target": "value"} +]""" . +``` + +--- + +## Action Types + +### Shape-Level Actions + +| Predicate | Purpose | Bound To | +| -------------------- | ----------------------------- | ----------------------------- | +| `ad4m://constructor` | Create instance with defaults | `{namespace}{ClassName}Shape` | +| `ad4m://destructor` | Remove instance and links | `{namespace}{ClassName}Shape` | + +### Property-Level Actions + +| Predicate | Purpose | Bound To | +| ---------------- | -------------------------- | ----------------------------------------- | +| `ad4m://setter` | Set single-valued property | `{namespace}{ClassName}.{propertyName}` | +| `ad4m://adder` | Add to collection | `{namespace}{ClassName}.{collectionName}` | +| `ad4m://remover` | Remove from collection | `{namespace}{ClassName}.{collectionName}` | + +### Action JSON Format + +```json +[ + { + "action": "addLink|removeLink|setSingleTarget|collectionSetter", + "source": "this|uuid|literal", + "predicate": "namespace://predicate", + "target": "value|*|specific_value", + "local": true // optional + } +] +``` + +--- + +## Querying SHACL Links + +### Get Constructor Actions + +```rust +// Find shape with constructor +let links = self.get_links(&LinkQuery { + predicate: Some("ad4m://constructor".to_string()), + ..Default::default() +}).await?; + +// Find one matching class name +for link in links { + if link.data.source.ends_with(&format!("{}Shape", class_name)) { + // Parse JSON from literal://string:{json} + let actions = parse_literal_json(&link.data.target)?; + return Ok(actions); + } +} +``` + +### Get Property Setter + +```rust +// Find property shape with setter +let prop_suffix = format!("{}.{}", class_name, property_name); +let links = self.get_links(&LinkQuery { + predicate: Some("ad4m://setter".to_string()), + ..Default::default() +}).await?; + +for link in links { + if link.data.source.ends_with(&prop_suffix) { + let actions = parse_literal_json(&link.data.target)?; + return Ok(actions); + } +} +``` + +--- + +## Implementation Status + +### Phase 1: SHACL Infrastructure (Complete) + +- [x] TypeScript: Generate SHACL JSON with actions in `generateSDNA()` +- [x] TypeScript: Serialize SHACL to links in `PerspectiveProxy.ensureSdnaLinks()` +- [x] Rust: Parse SHACL JSON in `shacl_parser.rs` +- [x] Rust: Store action links with separate predicates + +### Phase 2: Replace Prolog Queries (In Progress) + +- [x] `get_constructor_actions()` - Try SHACL first, fallback to Prolog +- [x] `get_destructor_actions()` - Try SHACL first, fallback to Prolog +- [x] `get_property_setter_actions()` - Try SHACL first, fallback to Prolog +- [x] `get_collection_adder_actions()` - Try SHACL first, fallback to Prolog +- [x] `get_collection_remover_actions()` - Try SHACL first, fallback to Prolog +- [x] `resolve_property_value()` - Try SHACL for resolve language +- [x] TypeScript `removeSubject()` - Try SHACL for destructor actions + +### Phase 3: Remove Prolog Fallbacks ✅ (Completed in this PR) + +> **Note:** Prolog engines (scryer-prolog) are kept available for complex queries and +> future advanced features. Only the _fallback pattern_ is removed - SHACL is the +> single source of truth for all SDNA actions. + +- [x] Remove Prolog fallbacks for action retrieval (SHACL-first is now SHACL-only) +- [x] Migrate Flows to same SHACL link pattern (`SHACLFlow` class with `toLinks()`/`fromLinks()`) +- [x] Keep scryer-prolog dependency (for complex Prolog queries later) +- [x] Refactor TypeScript to use `SHACLShape.fromLinks()` / `toJSON()` throughout +- [x] Use batched link operations (`addLinks()`) and single SurrealDB queries + +--- + +## Benefits + +1. **W3C Standard** - Interoperable with SHACL ecosystem +2. **Cleaner Runtime** - SHACL as single source for SDNA actions (Prolog still available for complex queries) +3. **Queryable** - All metadata as links in SurrealDB +4. **Debuggable** - Inspect schema as regular links +5. **Extensible** - Add new action types without schema changes + +--- + +## Files + +| File | Purpose | +| -------------------------------------------------------- | ------------------------------------------------- | +| `core/src/model/decorators.ts` | `generateSHACL()` - Creates SHACL from decorators | +| `core/src/shacl/SHACLShape.ts` | SHACL shape class with `toLinks()` | +| `core/src/perspectives/PerspectiveProxy.ts` | `ensureSdnaLinks()` - Stores SHACL as links | +| `rust-executor/src/perspectives/shacl_parser.rs` | Parses SHACL JSON, generates links | +| `rust-executor/src/perspectives/perspective_instance.rs` | Action retrieval with SHACL-first | diff --git a/core/src/index.ts b/core/src/index.ts index 6546e12bc..59d11ea07 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -31,3 +31,5 @@ export * from "./ai/AIClient" export * from "./ai/Tasks" export * from "./runtime/RuntimeResolver" export * from './model/Ad4mModel' +export * from './shacl/SHACLShape' +export { SHACLFlow, FlowState, FlowTransition, LinkPattern, FlowableCondition } from './shacl/SHACLFlow' diff --git a/core/src/model/Ad4mModel.ts b/core/src/model/Ad4mModel.ts index 241e191b7..d37de0547 100644 --- a/core/src/model/Ad4mModel.ts +++ b/core/src/model/Ad4mModel.ts @@ -1671,8 +1671,12 @@ WHERE ${whereConditions.join(' AND ')} // Only process if target has a value if (target !== undefined && target !== null && target !== '') { - // Check if we need to resolve a non-literal language expression - if (propMeta.resolveLanguage != undefined && propMeta.resolveLanguage !== 'literal' && typeof target === 'string') { + // Check if we need to resolve a non-literal language expression. + // resolveLanguage must be defined and not 'literal' to trigger expression resolution. + // Also skip if the target itself is a literal:// URI — those are handled by the + // literal-parsing branch below (avoids calling getExpression on empty literals like + // "literal://string:" which would cause a deserialization error). + if (propMeta.resolveLanguage != undefined && propMeta.resolveLanguage !== 'literal' && typeof target === 'string' && !target.startsWith('literal://')) { // For non-literal languages, resolve the expression via perspective.getExpression() // Note: Literals are already parsed by SurrealDB's fn::parse_literal() try { @@ -2236,6 +2240,11 @@ WHERE ${whereConditions.join(' AND ')} // Get resolve language from metadata (replaces Prolog query) let resolveLanguage = metadata.resolveLanguage; + // Skip storing empty/null/undefined values to avoid invalid empty literals (e.g. literal://string:) + if (value === undefined || value === null || value === "") { + return; + } + if (resolveLanguage) { value = await this.#perspective.createExpression(value, resolveLanguage); } @@ -2355,9 +2364,12 @@ WHERE ${whereConditions.join(' AND ')} } } + // Get the class name instead of passing the instance to avoid Prolog query generation + const className = await this.perspective.stringOrTemplateObjectToSubjectClassName(this); + // Create the subject with the initial values await this.perspective.createSubject( - this, + className, this.#baseExpression, initialValues, batchId diff --git a/core/src/model/decorators.ts b/core/src/model/decorators.ts index 44a915c42..2b8afcdcc 100644 --- a/core/src/model/decorators.ts +++ b/core/src/model/decorators.ts @@ -1,6 +1,7 @@ import { PerspectiveProxy } from "../perspectives/PerspectiveProxy"; import { Subject } from "./Subject"; import { capitalize, propertyNameToSetterName, singularToPlural, stringifyObjectLiteral } from "./util"; +import { SHACLShape, SHACLPropertyShape } from "../shacl/SHACLShape"; export class PerspectiveAction { action: string @@ -114,8 +115,8 @@ export function InstanceQuery(options?: InstanceQueryParams) { } // Fallback to SurrealDB (SdnaOnly mode) - // Get all instances first - let allInstances = await perspective.getAllSubjectInstances(subjectClassName) + // Get all instances first - pass the class constructor, not just the name + let allInstances = await perspective.getAllSubjectInstances(target) // Filter by where clause if provided if(options && options.where) { @@ -742,9 +743,9 @@ export function ModelOptions(opts: ModelOptionsOptions) { target: "value", ...(local && { local: true }) }] - collectionCode += `collection_adder(${uuid}, "${singularToPlural(collection)}", '${stringifyObjectLiteral(collectionAdderAction)}').\n` - collectionCode += `collection_remover(${uuid}, "${singularToPlural(collection)}", '${stringifyObjectLiteral(collectionRemoverAction)}').\n` - collectionCode += `collection_setter(${uuid}, "${singularToPlural(collection)}", '${stringifyObjectLiteral(collectionSetterAction)}').\n` + collectionCode += `collection_adder(${uuid}, "${collection}", '${stringifyObjectLiteral(collectionAdderAction)}').\n` + collectionCode += `collection_remover(${uuid}, "${collection}", '${stringifyObjectLiteral(collectionRemoverAction)}').\n` + collectionCode += `collection_setter(${uuid}, "${collection}", '${stringifyObjectLiteral(collectionSetterAction)}').\n` } collectionsCode.push(collectionCode) @@ -769,6 +770,221 @@ export function ModelOptions(opts: ModelOptionsOptions) { } } + // Generate SHACL shape (W3C standard + AD4M action definitions) + target.generateSHACL = function() { + const subjectName = opts.name; + const obj = target.prototype; + + // Determine namespace from first property or collection, or use default + let namespace = "ad4m://"; + const properties = obj.__properties || {}; + const collections = obj.__collections || {}; + + // Try properties first + if (Object.keys(properties).length > 0) { + const firstProp = properties[Object.keys(properties)[0]]; + if (firstProp.through) { + // Extract namespace from through predicate (e.g., "recipe://name" -> "recipe://") + const match = firstProp.through.match(/^([^:]+:\/\/)/); + if (match) { + namespace = match[1]; + } + } + } + // Fall back to collections if no properties + else if (Object.keys(collections).length > 0) { + const firstColl = collections[Object.keys(collections)[0]]; + if (firstColl.through) { + const match = firstColl.through.match(/^([^:]+:\/\/)/); + if (match) { + namespace = match[1]; + } + } + } + + // Create SHACL shape + const shapeUri = `${namespace}${subjectName}Shape`; + const targetClass = `${namespace}${subjectName}`; + const shape = new SHACLShape(shapeUri, targetClass); + + // === Extract Constructor Actions (same logic as generateSDNA) === + let constructorActions = []; + if(obj.subjectConstructor && obj.subjectConstructor.length) { + constructorActions = constructorActions.concat(obj.subjectConstructor); + } + + // === Extract Destructor Actions === + let destructorActions = []; + + // Convert properties to SHACL property shapes + for (const propName in properties) { + const propMeta = properties[propName]; + + if (!propMeta.through) continue; // Skip properties without predicates + + const propShape: SHACLPropertyShape = { + name: propName, // Property name for generating named URIs + path: propMeta.through, + }; + + // Determine datatype from initial value or resolveLanguage + if (propMeta.resolveLanguage === "literal") { + // If it resolves via literal language, it's likely a string + propShape.datatype = "xsd://string"; + } else if (propMeta.initial) { + // Try to infer from initial value type + const initialType = typeof obj[propName]; + if (initialType === "number") { + propShape.datatype = "xsd://integer"; + } else if (initialType === "boolean") { + propShape.datatype = "xsd://boolean"; + } else if (initialType === "string") { + propShape.datatype = "xsd://string"; + } + } + + // Cardinality constraints + if (propMeta.required) { + propShape.minCount = 1; + } + + // Single-valued properties get maxCount 1 + // (collections are handled separately below) + if (!propMeta.collection) { + propShape.maxCount = 1; + } + + // Flag properties have fixed value + if (propMeta.flag && propMeta.initial) { + propShape.hasValue = propMeta.initial; + } + + // AD4M-specific metadata + if (propMeta.local !== undefined) { + propShape.local = propMeta.local; + } + + if (propMeta.writable !== undefined) { + propShape.writable = propMeta.writable; + } + + if (propMeta.resolveLanguage) { + propShape.resolveLanguage = propMeta.resolveLanguage; + } + + // === Extract Setter Actions (same logic as generateSDNA) === + if (propMeta.setter) { + // Custom setter defined - not yet supported in SHACL + console.warn( + `[SHACL Generation] Custom Prolog setter for property '${propName}' in class '${subjectName}' is not yet supported. ` + + `The property will be created without setter actions. Consider using standard writable properties or provide explicit SHACL JSON.` + ); + // TODO: Parse custom Prolog setter to extract actions + } else if (propMeta.writable && propMeta.through) { + let setter = obj[propertyNameToSetterName(propName)]; + if (typeof setter === "function") { + propShape.setter = [{ + action: "setSingleTarget", + source: "this", + predicate: propMeta.through, + target: "value", + ...(propMeta.local && { local: true }) + }]; + } + } + + // Add to constructor actions if property has initial value + if (propMeta.initial) { + constructorActions.push({ + action: "addLink", + source: "this", + predicate: propMeta.through, + target: propMeta.initial, + }); + + // Add to destructor actions + destructorActions.push({ + action: "removeLink", + source: "this", + predicate: propMeta.through, + target: "*", + }); + } + + shape.addProperty(propShape); + } + + // Convert collections to SHACL property shapes + // (collections variable already declared above for namespace inference) + for (const collName in collections) { + const collMeta = collections[collName]; + + if (!collMeta.through) continue; + + const collShape: SHACLPropertyShape = { + name: collName, // Collection name for generating named URIs + path: collMeta.through, + // Collections have no maxCount (unlimited) + // minCount defaults to 0 (optional) + }; + + // Determine if it's a reference (IRI) or literal + // Collections typically contain references (IRIs) to other entities + // They're literals only if explicitly marked or contain primitive values + if (collMeta.where?.isInstance) { + // Collection of typed entities - definitely IRIs + collShape.nodeKind = 'IRI'; + } else { + // Default to IRI for collections (most common case) + // Literal collections are rare and would need explicit marking + collShape.nodeKind = 'IRI'; + } + + // AD4M-specific metadata + if (collMeta.local !== undefined) { + collShape.local = collMeta.local; + } + + if (collMeta.writable !== undefined) { + collShape.writable = collMeta.writable; + } + + // === Extract Collection Actions (adder/remover) === + // Adder action - adds a link to the collection + collShape.adder = [{ + action: "addLink", + source: "this", + predicate: collMeta.through, + target: "value", + ...(collMeta.local && { local: true }) + }]; + + // Remover action - removes a link from the collection + collShape.remover = [{ + action: "removeLink", + source: "this", + predicate: collMeta.through, + target: "value", + ...(collMeta.local && { local: true }) + }]; + + shape.addProperty(collShape); + } + + // Set constructor and destructor actions on the shape + if (constructorActions.length > 0) { + shape.setConstructorActions(constructorActions); + } + if (destructorActions.length > 0) { + shape.setDestructorActions(destructorActions); + } + + return { + shape, + name: subjectName + }; + } + Object.defineProperty(target, 'type', {configurable: true}); } } diff --git a/core/src/model/util.ts b/core/src/model/util.ts index b91d4c7f3..9719f594c 100644 --- a/core/src/model/util.ts +++ b/core/src/model/util.ts @@ -35,11 +35,12 @@ export function collectionToAdderName(collection: string): string { return `add${capitalize(pluralToSingular(collection))}` } -// e.g. "addEntry" -> "entries" +// e.g. "addComments" -> "comments" export function collectionAdderToName(adderName: string): string { - let singular = adderName.substring(3) - let plural = singularToPlural(singular) - return plural.charAt(0).toLowerCase() + plural.slice(1) + // Extract the collection name after "add" and lowercase first char + // The method name already has the plural collection name (e.g., "addComments") + let collectionName = adderName.substring(3) + return collectionName.charAt(0).toLowerCase() + collectionName.slice(1) } // e.g. "comments" -> "removeComment" @@ -47,17 +48,19 @@ export function collectionToRemoverName(collection: string): string { return `remove${capitalize(pluralToSingular(collection))}` } -// e.g. "removeEntry" -> "entries" +// e.g. "removeComments" -> "comments" export function collectionRemoverToName(removerName: string): string { - let singular = removerName.substring(6) - let plural = singularToPlural(singular) - return plural.charAt(0).toLowerCase() + plural.slice(1) + // Extract the collection name after "remove" and lowercase first char + // The method name already has the plural collection name (e.g., "removeComments") + let collectionName = removerName.substring(6) + return collectionName.charAt(0).toLowerCase() + collectionName.slice(1) } -export function collectionSetterToName(adderName: string): string { - let singular = adderName.substring(13) - let plural = singularToPlural(singular) - return plural.charAt(0).toLowerCase() + plural.slice(1) +export function collectionSetterToName(setterName: string): string { + // Extract the collection name after "setCollection" and lowercase first char + // The method name already has the plural collection name (e.g., "setCollectionComments") + let collectionName = setterName.substring(13) + return collectionName.charAt(0).toLowerCase() + collectionName.slice(1) } // e.g. "comments" -> "addComment" diff --git a/core/src/perspectives/PerspectiveClient.ts b/core/src/perspectives/PerspectiveClient.ts index 5cc2f26e6..b21ecf03a 100644 --- a/core/src/perspectives/PerspectiveClient.ts +++ b/core/src/perspectives/PerspectiveClient.ts @@ -430,11 +430,22 @@ export class PerspectiveClient { return perspectiveRemoveLink } - async addSdna(uuid: string, name: string, sdnaCode: string, sdnaType: "subject_class" | "flow" | "custom"): Promise { + /** + * Adds Social DNA code to a perspective. + * + * Preferred usage: pass shaclJson (from SHACLShape.toJSON()) as the primary schema definition. + * The sdnaCode parameter is kept for backward compatibility but SHACL is the source of truth + * for all SDNA operations. Prolog engines remain available for complex queries. + * + * @param sdnaCode - Legacy Prolog code (pass empty string when using shaclJson) + * @param shaclJson - SHACL JSON string from SHACLShape.toJSON() (recommended) + */ + async addSdna(uuid: string, name: string, sdnaCode: string | undefined, sdnaType: "subject_class" | "flow" | "custom", shaclJson?: string): Promise { return unwrapApolloResult(await this.#apolloClient.mutate({ - mutation: gql`mutation perspectiveAddSdna($uuid: String!, $name: String!, $sdnaCode: String!, $sdnaType: String!) { - perspectiveAddSdna(uuid: $uuid, name: $name, sdnaCode: $sdnaCode, sdnaType: $sdnaType) - }`, variables: { uuid, name, sdnaCode, sdnaType } + mutation: gql`mutation perspectiveAddSdna($uuid: String!, $name: String!, $sdnaCode: String, $sdnaType: String!, $shaclJson: String) { + perspectiveAddSdna(uuid: $uuid, name: $name, sdnaCode: $sdnaCode, sdnaType: $sdnaType, shaclJson: $shaclJson) + }`, + variables: { uuid, name, sdnaCode: sdnaCode || "", sdnaType, shaclJson } })).perspectiveAddSdna } diff --git a/core/src/perspectives/PerspectiveProxy.ts b/core/src/perspectives/PerspectiveProxy.ts index 03999b860..30183e6c5 100644 --- a/core/src/perspectives/PerspectiveProxy.ts +++ b/core/src/perspectives/PerspectiveProxy.ts @@ -6,7 +6,6 @@ import { Perspective } from "./Perspective"; import { Literal } from "../Literal"; import { Subject } from "../model/Subject"; import { ExpressionRendered } from "../expression/Expression"; -import { collectionAdderToName, collectionRemoverToName, collectionSetterToName } from "../model/util"; import { NeighbourhoodProxy } from "../neighbourhood/NeighbourhoodProxy"; import { NeighbourhoodExpression } from "../neighbourhood/Neighbourhood"; import { AIClient } from "../ai/AIClient"; @@ -14,6 +13,8 @@ import { PERSPECTIVE_QUERY_SUBSCRIPTION } from "./PerspectiveResolver"; import { gql } from "@apollo/client/core"; import { AllInstancesResult } from "../model/Ad4mModel"; import { escapeSurrealString } from "../utils"; +import { SHACLShape } from "../shacl/SHACLShape"; +import { SHACLFlow, LinkPattern } from "../shacl/SHACLFlow"; type QueryCallback = (result: AllInstancesResult) => void; @@ -856,48 +857,122 @@ export class PerspectiveProxy { /** Returns all the Social DNA flows defined in this perspective */ async sdnaFlows(): Promise { - const allFlows = await this.infer("register_sdna_flow(X, _)") - return allFlows.map(x => x.X) + // Query for all flow registration links + const flowLinks = await this.get(new LinkQuery({ + source: "ad4m://self", + predicate: "ad4m://has_flow" + })); + return flowLinks.map(l => { + try { + return Literal.fromUrl(l.data.target).get() as string; + } catch { + return l.data.target; + } + }); } /** Returns all Social DNA flows that can be started from the given expression */ async availableFlows(exprAddr: string): Promise { - const availableFlows = await this.infer(`flowable("${exprAddr}", F), register_sdna_flow(X, F)`) - return availableFlows.map(x => x.X) + const allFlowNames = await this.sdnaFlows(); + const available: string[] = []; + for (const name of allFlowNames) { + const flow = await this.getFlow(name); + if (!flow) continue; + if (flow.flowable === "any") { + available.push(name); + } else { + // Check if the expression matches the flowable link pattern + const pattern = flow.flowable as LinkPattern; + const source = pattern.source || exprAddr; + const links = await this.get(new LinkQuery({ + source, + predicate: pattern.predicate, + target: pattern.target + })); + if (links.length > 0) { + available.push(name); + } + } + } + return available; } /** Starts the Social DNA flow @param flowName on the expression @param exprAddr */ async startFlow(flowName: string, exprAddr: string) { - let startAction = await this.infer(`start_action(Action, F), register_sdna_flow("${flowName}", F)`) - // should always return one solution... - startAction = eval(startAction[0].Action) - await this.executeAction(startAction, exprAddr, undefined) + const flow = await this.getFlow(flowName); + if (!flow) throw `Flow "${flowName}" not found`; + if (flow.startAction.length === 0) throw `Flow "${flowName}" has no start action`; + await this.executeAction(flow.startAction, exprAddr, undefined) } /** Returns all expressions in the given state of given Social DNA flow */ async expressionsInFlowState(flowName: string, flowState: number): Promise { - let expressions = await this.infer(`register_sdna_flow("${flowName}", F), flow_state(X, ${flowState}, F)`) - return expressions.map(r => r.X) + const flow = await this.getFlow(flowName); + if (!flow) return []; + // Find the state with the matching value + const state = flow.states.find(s => s.value === flowState); + if (!state) return []; + // Query for expressions matching this state's check pattern + const pattern = state.stateCheck; + const links = await this.get(new LinkQuery({ + predicate: pattern.predicate, + target: pattern.target + })); + // Return the sources (expression addresses) - use source if pattern has no explicit source + return links.map(l => pattern.source ? l.data.target : l.data.source); } /** Returns the given expression's flow state with regard to given Social DNA flow */ async flowState(flowName: string, exprAddr: string): Promise { - let state = await this.infer(`register_sdna_flow("${flowName}", F), flow_state("${exprAddr}", X, F)`) - return state[0].X + const flow = await this.getFlow(flowName); + if (!flow) throw `Flow "${flowName}" not found`; + // Check each state to find which one the expression is in + for (const state of flow.states) { + const pattern = state.stateCheck; + const source = pattern.source || exprAddr; + const links = await this.get(new LinkQuery({ + source, + predicate: pattern.predicate, + target: pattern.target + })); + if (links.length > 0) return state.value; + } + throw `Expression "${exprAddr}" is not in any state of flow "${flowName}"`; } /** Returns available action names, with regard to Social DNA flow and expression's flow state */ async flowActions(flowName: string, exprAddr: string): Promise { - let actionNames = await this.infer(`register_sdna_flow("${flowName}", Flow), flow_state("${exprAddr}", State, Flow), action(State, Name, _, _)`) - return actionNames.map(r => r.Name) + const flow = await this.getFlow(flowName); + if (!flow) return []; + // Determine current state + let currentStateName: string | null = null; + for (const state of flow.states) { + const pattern = state.stateCheck; + const source = pattern.source || exprAddr; + const links = await this.get(new LinkQuery({ + source, + predicate: pattern.predicate, + target: pattern.target + })); + if (links.length > 0) { + currentStateName = state.name; + break; + } + } + if (!currentStateName) return []; + // Return transitions available from current state + return flow.transitions + .filter(t => t.fromState === currentStateName) + .map(t => t.actionName); } /** Runs given Social DNA flow action */ async runFlowAction(flowName: string, exprAddr: string, actionName: string) { - let action = await this.infer(`register_sdna_flow("${flowName}", Flow), flow_state("${exprAddr}", State, Flow), action(State, "${actionName}", _, Action)`) - // should find only one - action = eval(action[0].Action) - await this.executeAction(action, exprAddr, undefined) + const flow = await this.getFlow(flowName); + if (!flow) throw `Flow "${flowName}" not found`; + const transition = flow.transitions.find(t => t.actionName === actionName); + if (!transition) throw `Action "${actionName}" not found in flow "${flowName}"`; + await this.executeAction(transition.actions, exprAddr, undefined) } /** Returns the perspective's Social DNA code @@ -975,17 +1050,280 @@ export class PerspectiveProxy { return typeof code === 'string' ? code : null; } - /** Adds the given Social DNA code to the perspective's SDNA code */ - async addSdna(name: string, sdnaCode: string, sdnaType: "subject_class" | "flow" | "custom") { - return this.#client.addSdna(this.#handle.uuid, name, sdnaCode, sdnaType) + /** + * Adds Social DNA code to the perspective. + * + * **Recommended:** Use {@link addShacl} instead, which accepts the `SHACLShape` type directly. + * This method is primarily for the GraphQL layer and legacy Prolog code. + * + * @param name - Unique name for this SDNA definition + * @param sdnaCode - Prolog SDNA code (legacy, can be empty string if shaclJson provided) + * @param sdnaType - Type of SDNA: "subject_class", "flow", or "custom" + * @param shaclJson - SHACL JSON string (use addShacl() for type-safe alternative) + * + * @example + * // Recommended: Use addShacl() with SHACLShape type + * const shape = new SHACLShape('recipe://Recipe'); + * shape.addProperty({ name: 'title', path: 'recipe://title', datatype: 'xsd:string' }); + * await perspective.addShacl('Recipe', shape); + * + * // Legacy: Prolog code is auto-converted to SHACL + * await perspective.addSdna('Recipe', prologCode, 'subject_class'); + */ + async addSdna(name: string, sdnaCode: string, sdnaType: "subject_class" | "flow" | "custom", shaclJson?: string) { + return this.#client.addSdna(this.#handle.uuid, name, sdnaCode, sdnaType, shaclJson) } - /** Returns all the Subject classes defined in this perspectives SDNA */ + /** + * **Recommended way to add SDNA schemas.** + * + * Store a SHACL shape in this Perspective using the type-safe `SHACLShape` class. + * The shape is serialized as RDF triples (links) for native AD4M storage and querying. + * + * @param name - Unique name for this schema (e.g., 'Recipe', 'Task') + * @param shape - SHACLShape instance defining the schema + * + * @example + * import { SHACLShape } from '@coasys/ad4m'; + * + * const shape = new SHACLShape('recipe://Recipe'); + * shape.addProperty({ + * name: 'title', + * path: 'recipe://title', + * datatype: 'xsd:string', + * minCount: 1 + * }); + * shape.addProperty({ + * name: 'ingredients', + * path: 'recipe://has_ingredient', + * // No maxCount = collection + * }); + * + * await perspective.addShacl('Recipe', shape); + */ + async addShacl(name: string, shape: SHACLShape): Promise { + // Serialize shape to links + const shapeLinks = shape.toLinks(); + + // Create name -> shape mapping links + const nameMapping = Literal.fromUrl(`literal://string:shacl://${name}`); + const allLinks: Link[] = [ + ...shapeLinks.map(l => new Link({ + source: l.source, + predicate: l.predicate, + target: l.target + })), + new Link({ + source: "ad4m://self", + predicate: "ad4m://has_shacl", + target: nameMapping.toUrl() + }), + new Link({ + source: nameMapping.toUrl(), + predicate: "ad4m://shacl_shape_uri", + target: shape.nodeShapeUri + }) + ]; + + // Batch add all links at once + await this.addLinks(allLinks); + } + + /** + * Retrieve a SHACL shape by name from this Perspective + */ + async getShacl(name: string): Promise { + // Find the shape URI from the name mapping + const nameMapping = Literal.fromUrl(`literal://string:shacl://${name}`); + const shapeUriLinks = await this.get(new LinkQuery({ + source: nameMapping.toUrl(), + predicate: "ad4m://shacl_shape_uri" + })); + + if (shapeUriLinks.length === 0) { + return null; + } + + const shapeUri = shapeUriLinks[0].data.target; + const escapedShapeUri = escapeSurrealString(shapeUri); + + // First get property shape URIs so we can query everything in one go + const propertyLinks = await this.get(new LinkQuery({ + source: shapeUri, + predicate: "sh://property" + })); + + // Build a single surreal query that fetches all relevant links + const sourceUris = [shapeUri, ...propertyLinks.map(l => l.data.target)]; + const escapedSources = sourceUris.map(u => `'${escapeSurrealString(u)}'`).join(', '); + + const query = `SELECT in.uri AS source, predicate, out.uri AS target FROM link WHERE in.uri IN [${escapedSources}]`; + const result = await this.querySurrealDB(query); + + const shapeLinks = (result || []).map((r: any) => ({ + source: r.source, + predicate: r.predicate, + target: r.target + })); + + return SHACLShape.fromLinks(shapeLinks, shapeUri); + } + + /** + * Get all SHACL shapes stored in this Perspective + */ + async getAllShacl(): Promise> { + const nameLinks = await this.get(new LinkQuery({ + source: "ad4m://self", + predicate: "ad4m://has_shacl" + })); + + const shapes = []; + for (const nameLink of nameLinks) { + const nameUrl = nameLink.data.target; + const name = Literal.fromUrl(nameUrl).get() as string; + const shapeName = name.replace('shacl://', ''); + + const shape = await this.getShacl(shapeName); + if (shape) { + shapes.push({ name: shapeName, shape }); + } + } + + return shapes; + } + + /** + * **Recommended way to add Flow definitions.** + * + * Store a SHACL Flow (state machine) in this Perspective using the type-safe `SHACLFlow` class. + * The flow is serialized as RDF triples (links) for native AD4M storage and querying. + * + * @param name - Flow name (e.g., 'TODO', 'Approval') + * @param flow - SHACLFlow instance defining the state machine + * + * @example + * ```typescript + * import { SHACLFlow } from '@coasys/ad4m'; + * + * const todoFlow = new SHACLFlow('TODO', 'todo://'); + * todoFlow.flowable = 'any'; + * + * // Define states + * todoFlow.addState({ name: 'ready', value: 0, stateCheck: { predicate: 'todo://state', target: 'todo://ready' }}); + * todoFlow.addState({ name: 'done', value: 1, stateCheck: { predicate: 'todo://state', target: 'todo://done' }}); + * + * // Define start action + * todoFlow.startAction = [{ action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' }]; + * + * // Define transitions + * todoFlow.addTransition({ + * actionName: 'Complete', + * fromState: 'ready', + * toState: 'done', + * actions: [ + * { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }, + * { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + * ] + * }); + * + * await perspective.addFlow('TODO', todoFlow); + * ``` + */ + async addFlow(name: string, flow: SHACLFlow): Promise { + // Serialize flow to links + const flowLinks = flow.toLinks(); + + // Create registration and mapping links + const flowNameLiteral = Literal.from(name).toUrl(); + const allLinks: Link[] = [ + ...flowLinks.map(l => new Link({ + source: l.source, + predicate: l.predicate, + target: l.target + })), + new Link({ + source: "ad4m://self", + predicate: "ad4m://has_flow", + target: flowNameLiteral + }), + new Link({ + source: flowNameLiteral, + predicate: "ad4m://flow_uri", + target: flow.flowUri + }) + ]; + + // Batch add all links at once + await this.addLinks(allLinks); + } + + /** + * Retrieve a Flow definition by name from this Perspective + * + * @param name - Flow name to retrieve + * @returns The SHACLFlow or null if not found + */ + async getFlow(name: string): Promise { + const flowNameLiteral = Literal.from(name).toUrl(); + + // Find flow URI from name mapping + const flowUriLinks = await this.get(new LinkQuery({ + source: flowNameLiteral, + predicate: "ad4m://flow_uri" + })); + + if (flowUriLinks.length === 0) { + return null; + } + + const flowUri = flowUriLinks[0].data.target; + const escapedFlowUri = escapeSurrealString(flowUri); + + // Compute alternate prefix for state/transition URIs + const alternatePrefix = flowUri.endsWith('Flow') + ? flowUri.slice(0, -4) + '.' + : flowUri + '.'; + const escapedAltPrefix = escapeSurrealString(alternatePrefix); + + // Single surreal query to get all flow-related links + const query = `SELECT in.uri AS source, predicate, out.uri AS target FROM link WHERE in.uri = '${escapedFlowUri}' OR string::starts_with(in.uri, '${escapedAltPrefix}')`; + const result = await this.querySurrealDB(query); + + const flowLinks = (result || []).map((r: any) => ({ + source: r.source, + predicate: r.predicate, + target: r.target + })); + + return SHACLFlow.fromLinks(flowLinks, flowUri); + } + + /** Returns all the Subject classes defined in this perspectives SDNA + * + * Uses SHACL-based lookup (Prolog-free implementation). + */ async subjectClasses(): Promise { try { - return (await this.infer("subject_class(X, _)")).map(x => x.X) - }catch(e) { - return [] + // Query SHACL class links directly — no need for a separate GraphQL endpoint + const classLinks = await this.get(new LinkQuery({ + predicate: "rdf://type", + target: "ad4m://SubjectClass" + })); + const classNames = classLinks + .map(l => { + const source = l.data.source; + // Extract class name from URI like "recipe://Recipe" or "flux://Channel" + const parts = source.split("://"); + const lastPart = parts[parts.length - 1]; + return lastPart.split('/').pop() || ''; + }) + .filter(name => name.length > 0); + // Deduplicate + return [...new Set(classNames)]; + } catch (e) { + console.warn('subjectClasses: SHACL lookup failed:', e); + return []; } } @@ -1036,11 +1374,16 @@ export class PerspectiveProxy { batchId ); } else { - let query = this.buildQueryFromTemplate(subjectClass as object); + const obj = subjectClass as any; + const resolvedClassName = obj.className || obj.constructor?.className || obj.constructor?.prototype?.className + || await this.findClassByProperties(obj); + if (!resolvedClassName) { + throw new Error("Could not resolve subject class name from object. Use a decorated class or pass a className string."); + } await this.#client.createSubject( this.#handle.uuid, JSON.stringify({ - query, + className: resolvedClassName, initialValues }), exprAddr, @@ -1061,8 +1404,46 @@ export class PerspectiveProxy { if (typeof subjectClass === "string") { return JSON.parse(await this.#client.getSubjectData(this.#handle.uuid, JSON.stringify({className: subjectClass}), exprAddr)) } - let query = this.buildQueryFromTemplate(subjectClass as object) - return JSON.parse(await this.#client.getSubjectData(this.#handle.uuid, JSON.stringify({query}), exprAddr)) + const obj = subjectClass as any; + const resolvedClassName = obj.className || obj.constructor?.className || obj.constructor?.prototype?.className + || await this.findClassByProperties(obj); + if (!resolvedClassName) { + throw new Error("Could not resolve subject class name from object. Use a decorated class or pass a className string."); + } + return JSON.parse(await this.#client.getSubjectData(this.#handle.uuid, JSON.stringify({className: resolvedClassName}), exprAddr)) + } + + /** + * Gets actions from SHACL links for a given predicate (e.g., ad4m://constructor, ad4m://destructor). + * Returns the parsed action array if found, or null if not found. + */ + private async getActionsFromSHACL(className: string, predicate: string): Promise { + // Use regex to match exact class name followed by "Shape" at end of URI + // This prevents "RecipeShape" from matching "MyRecipeShape" + const escaped = this.escapeRegExp(className); + const shapePattern = new RegExp(`[/:#]${escaped}Shape$`); + const links = await this.get(new LinkQuery({ predicate })); + + for (const link of links) { + if (shapePattern.test(link.data.source)) { + // Parse actions from literal://string:{json} + const prefix = "literal://string:"; + if (link.data.target.startsWith(prefix)) { + const jsonStr = link.data.target.slice(prefix.length); + // Decode URL-encoded JSON if needed, with fallback for raw % characters + let decoded = jsonStr; + try { decoded = decodeURIComponent(jsonStr); } catch {} + try { + return JSON.parse(decoded); + } catch (e) { + console.warn(`Failed to parse SHACL actions JSON for ${className}:`, e); + return null; + } + } + } + } + + return null; } /** Removes a subject instance by running its (SDNA defined) destructor, @@ -1077,13 +1458,15 @@ export class PerspectiveProxy { */ async removeSubject(subjectClass: T, exprAddr: string, batchId?: string) { let className = await this.stringOrTemplateObjectToSubjectClassName(subjectClass) - let result = await this.infer(`subject_class("${className}", C), destructor(C, Actions)`) - if(!result.length) { - throw "No destructor found for given subject class: " + className + + // Get destructor actions from SHACL links (Prolog-free) + let actions = await this.getActionsFromSHACL(className, "ad4m://destructor"); + + if (!actions) { + throw `No destructor found for subject class: ${className}. Make sure the class was registered with SHACL.`; } - let actions = result.map(x => eval(x.Actions)) - await this.executeAction(actions[0], exprAddr, undefined, batchId) + await this.executeAction(actions, exprAddr, undefined, batchId) } /** Checks if the given expression is a subject instance of the given subject class @@ -1095,20 +1478,11 @@ export class PerspectiveProxy { async isSubjectInstance(expression: string, subjectClass: T): Promise { let className = await this.stringOrTemplateObjectToSubjectClassName(subjectClass) - // Get metadata from SDNA using Prolog metaprogramming + // Get metadata from SHACL links const metadata = await this.getSubjectClassMetadataFromSDNA(className); if (!metadata) { - // Fallback to Prolog check if SDNA metadata isn't available - // This handles cases where classes exist in Prolog but not in SDNA - try { - const escapedClassName = className.replace(/"/g, '\\"'); - const escapedExpression = expression.replace(/"/g, '\\"'); - const result = await this.infer(`subject_class("${escapedClassName}", C), instance(C, "${escapedExpression}")`); - return result && result.length > 0; - } catch (e) { - console.warn(`Failed to check instance via Prolog for class ${className}:`, e); - return false; - } + console.warn(`isSubjectInstance: No SHACL metadata found for class ${className}`); + return false; } // If no required triples, any expression with links is an instance @@ -1173,10 +1547,8 @@ export class PerspectiveProxy { } /** - * Extracts subject class metadata from SDNA by parsing the Prolog text. - * Parses the instance rule to extract required predicates. - * Returns required predicates that define what makes something an instance, - * plus a map of property/collection names to their predicates. + * Gets subject class metadata from SHACL links using SHACLShape.fromLinks(). + * Retrieves the SHACL shape and extracts metadata for instance queries. */ async getSubjectClassMetadataFromSDNA(className: string): Promise<{ requiredPredicates: string[], @@ -1185,182 +1557,59 @@ export class PerspectiveProxy { collections: Map } | null> { try { - // Get SDNA code from perspective - it's stored as a link - // Use canonical Literal.from() to construct the source URL - const sdnaLinks = await this.get(new LinkQuery({ - source: Literal.from(className).toUrl(), - predicate: "ad4m://sdna" - })); - - //console.log(`getSubjectClassMetadataFromSDNA: sdnaLinks for ${className}:`, sdnaLinks); - - if (!sdnaLinks || sdnaLinks.length === 0) { - console.warn(`No SDNA found for class ${className}`); - return null; - } - - if (!sdnaLinks[0].data.target) { - console.error(`SDNA link for ${className} has no target:`, sdnaLinks[0]); + // Use getShacl() to retrieve the shape via SHACLShape.fromLinks() + const shape = await this.getShacl(className); + if (!shape) { + console.warn(`No SHACL metadata found for ${className}`); return null; } - // Extract SDNA code from the literal - const sdnaCode = Literal.fromUrl(sdnaLinks[0].data.target).get(); - //console.log("sdnaCode for", className, ":", sdnaCode.substring(0, 200)); - - // Store required triples as {predicate, target?} - // target is only set for flags (exact matches), otherwise undefined + const requiredPredicates: string[] = []; const requiredTriples: Array<{predicate: string, target?: string}> = []; - - // Parse the instance rule from the SDNA code - // Format: instance(c, Base) :- triple(Base, "pred1", _), triple(Base, "pred2", "exact_value"). - // Use a more robust pattern that handles complex rule bodies - // Match from "instance(" to the closing "." using non-greedy matching - const instanceRulePattern = /instance\([^)]+\)\s*:-\s*([^.]+)\./g; - let instanceRuleMatch; - let foundInstanceRule = false; - - while ((instanceRuleMatch = instanceRulePattern.exec(sdnaCode)) !== null) { - foundInstanceRule = true; - const ruleBody = instanceRuleMatch[1]; - - // Extract all triple(Base, "predicate", Target) patterns - // Match both: triple(Base, "pred", _) and triple(Base, "pred", "value") - const tripleRegex = /triple\([^,]+,\s*"([^"]+)",\s*(?:"([^"]+)"|_)\)/g; - let match; - - while ((match = tripleRegex.exec(ruleBody)) !== null) { - const predicate = match[1]; - const target = match[2]; // undefined if matched "_" - requiredTriples.push({ predicate, target }); - } - } - - if (!foundInstanceRule) { - console.warn(`No instance rule found in SDNA for ${className}`); - } - - // For backward compatibility, also maintain requiredPredicates array - const requiredPredicates = requiredTriples.map(t => t.predicate); - - // Extract property metadata const properties = new Map(); - const propertyResults = await this.infer(`subject_class("${className}", C), property(C, P)`); - //console.log("propertyResults", propertyResults); - - if (propertyResults) { - for (const result of propertyResults) { - const propName = result.P; - let predicate: string | null = null; - - // Try to extract predicate from property_setter first - const setterResults = await this.infer(`subject_class("${className}", C), property_setter(C, "${propName}", Setter)`); - if (setterResults && setterResults.length > 0) { - const setterString = setterResults[0].Setter; - const predicateMatch = setterString.match(/predicate:\s*"([^"]+)"|predicate:\s*([^,}\]]+)/); - if (predicateMatch) { - predicate = predicateMatch[1] || predicateMatch[2]; - } - } + const collections = new Map(); - // If no setter, try to extract from SDNA property_getter Prolog code - if (!predicate) { - // Parse the SDNA code for property_getter definition - // Escape propName to prevent regex injection and ReDoS attacks - const escapedPropName = this.escapeRegExp(propName); - const getterMatch = sdnaCode.match(new RegExp(`property_getter\\([^,]+,\\s*[^,]+,\\s*"${escapedPropName}"[^)]*\\)\\s*:-\\s*triple\\([^,]+,\\s*"([^"]+)"`)); - if (getterMatch) { - predicate = getterMatch[1]; - } - } + // Build property/collection maps and track writable predicates from shape properties + const writablePredicates = new Set(); + for (const prop of shape.properties) { + if (!prop.path || !prop.name) continue; - if (predicate) { - // Check if property has resolveLanguage - const resolveResults = await this.infer(`subject_class("${className}", C), property_resolve_language(C, "${propName}", Lang)`); - const resolveLanguage = resolveResults && resolveResults.length > 0 ? resolveResults[0].Lang : undefined; + if (prop.writable) { + writablePredicates.add(prop.path); + } - properties.set(propName, { predicate, resolveLanguage }); - } + const isCollection = prop.adder && prop.adder.length > 0; + if (isCollection) { + collections.set(prop.name, { predicate: prop.path }); + } else { + properties.set(prop.name, { + predicate: prop.path, + resolveLanguage: prop.resolveLanguage + }); } } - //console.log("properties", properties); - - // Extract collection metadata - const collections = new Map(); - const collectionResults = await this.infer(`subject_class("${className}", C), collection(C, Coll)`); - //console.log("collectionResults", collectionResults); - if (collectionResults) { - for (const result of collectionResults) { - const collName = result.Coll; - let predicate: string | null = null; - let instanceFilter: string | undefined = undefined; - let condition: string | undefined = undefined; - - // Try to extract predicate from collection_adder first - const adderResults = await this.infer(`subject_class("${className}", C), collection_adder(C, "${collName}", Adder)`); - if (adderResults && adderResults.length > 0) { - const adderString = adderResults[0].Adder; - const predicateMatch = adderString.match(/predicate:\s*"([^"]+)"|predicate:\s*([^,}\]]+)/); - if (predicateMatch) { - predicate = predicateMatch[1] || predicateMatch[2]; - } - } - - // Note: condition is not stored in SDNA, it's read from class metadata - // It will be populated by augmentMetadataWithCollectionOptions() when needed - - // Parse collection_getter from SDNA to extract predicate and instanceFilter - // Format 1 (findall): collection_getter(c, Base, "comments", List) :- findall(C, triple(Base, "todo://comment", C), List). - // Format 2 (setof): collection_getter(c, Base, "messages", List) :- setof(Target, (triple(Base, "flux://entry_type", Target), ...), List). - // Use a line-based match to avoid capturing multiple collections - // Escape collName to prevent regex injection and ReDoS attacks - const escapedCollName = this.escapeRegExp(collName); - const getterLinePattern = new RegExp(`collection_getter\\([^,]+,\\s*[^,]+,\\s*"${escapedCollName}"[^)]*\\)\\s*:-[^.]+\\.`); - const getterLineMatch = sdnaCode.match(getterLinePattern); - - if (getterLineMatch) { - const getterLine = getterLineMatch[0]; - // Extract the body between setof/findall and the final ). - // Pattern: findall(Var, Body, List) or setof(Var, (Body), List) - const bodyPattern = /(?:setof|findall)\([^,]+,\s*(.+),\s*\w+\)\./; - const bodyMatch = getterLine.match(bodyPattern); - - if (bodyMatch) { - let getterBody = bodyMatch[1]; - // Remove outer parentheses if present (setof case) - if (getterBody.startsWith('(') && getterBody.endsWith(')')) { - getterBody = getterBody.substring(1, getterBody.length - 1); - } - - // Extract predicate from triple(Base, "predicate", Target) - if (!predicate) { - const tripleMatch = getterBody.match(/triple\([^,]+,\s*"([^"]+)"/); - if (tripleMatch) { - predicate = tripleMatch[1]; - } - } - - // Check for instance filter: subject_class("ClassName", OtherClass) - const instanceMatch = getterBody.match(/subject_class\("([^"]+)"/); - if (instanceMatch) { - instanceFilter = instanceMatch[1]; - } - } - } - if (predicate) { - collections.set(collName, { predicate, instanceFilter, condition }); + // Extract required predicates/triples from constructor actions + if (shape.constructor_actions) { + for (const action of shape.constructor_actions) { + if (action.predicate) { + requiredPredicates.push(action.predicate); + const isWritableProperty = writablePredicates.has(action.predicate); + if (action.target && action.target !== 'value' && !isWritableProperty) { + requiredTriples.push({ predicate: action.predicate, target: action.target }); + } else { + requiredTriples.push({ predicate: action.predicate }); + } } } } - //console.log("collections", collections); + return { requiredPredicates, requiredTriples, properties, collections }; } catch (e) { - console.error(`Error getting metadata for ${className}:`, e); + console.error(`Error getting SHACL metadata for ${className}:`, e); return null; } } - /** * Generates a SurrealDB query to find instances based on class metadata. */ @@ -1596,8 +1845,13 @@ export class PerspectiveProxy { */ async getAllSubjectInstances(subjectClass: T): Promise { let classes = [] + let isClassConstructor = typeof subjectClass === "function" if(typeof subjectClass === "string") { classes = [subjectClass] + } else if (isClassConstructor) { + // It's an Ad4mModel class constructor + //@ts-ignore + classes = [subjectClass.name] } else { classes = await this.subjectClassesByTemplate(subjectClass as object) } @@ -1616,9 +1870,19 @@ export class PerspectiveProxy { for (const result of results || []) { //console.log(`getAllSubjectInstances: Creating subject for base ${result.base}`); try { - let subject = new Subject(this, result.base, className); - await subject.init(); - instances.push(subject as unknown as T); + let instance; + if (isClassConstructor) { + // Create an instance of the actual Ad4mModel class + //@ts-ignore + instance = new subjectClass(this, result.base); + // Load the instance data from links + await instance.get(); + } else { + // Legacy: Create a Subject proxy + instance = new Subject(this, result.base, className); + await instance.init(); + } + instances.push(instance as unknown as T); //console.log(`getAllSubjectInstances: Successfully created subject for ${result.base}`); } catch (e) { //console.warn(`Failed to create subject for ${result.base}:`, e); @@ -1668,97 +1932,88 @@ export class PerspectiveProxy { } - private buildQueryFromTemplate(obj: object): string { - let result - // We need to avoid strict mode for the following intropsective code - (function(obj) { - // Collect all string properties of the object in a list - let properties = [] - - // Collect all collections of the object in a list - let collections = [] - - // Collect all string properties of the object in a list - if(Object.getPrototypeOf(obj).__properties) { - Object.keys(Object.getPrototypeOf(obj).__properties).forEach(p => properties.push(p)) - } else { - properties.push(...Object.keys(obj).filter(key => !Array.isArray(obj[key]))) - } - - // Collect all collections of the object in a list - if (Object.getPrototypeOf(obj).__collections) { - Object.keys(Object.getPrototypeOf(obj).__collections).filter(key => key !== 'isSubjectInstance').forEach(c => { - if (!collections.includes(c)) { - collections.push(c); - } - }); - } else { - collections.push(...Object.keys(obj).filter(key => Array.isArray(obj[key])).filter(key => key !== 'isSubjectInstance')) - } - - // Collect all set functions of the object in a list - let setFunctions = Object.getOwnPropertyNames(obj).filter(key => (typeof obj[key] === "function") && key.startsWith("set") && !key.startsWith("setCollection")) - // Add all set functions of the object's prototype to that list - setFunctions = setFunctions.concat(Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).filter(key => { - const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), key); - return descriptor && typeof descriptor.value === "function" && key.startsWith("set") && !key.startsWith("setCollection"); - })); - - // Collect all add functions of the object in a list - let addFunctions = Object.getOwnPropertyNames(obj).filter(key => (Object.prototype.hasOwnProperty.call(obj, key) && typeof obj[key] === "function") && key.startsWith("add")) - // Add all add functions of the object's prototype to that list - addFunctions = addFunctions.concat(Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).filter(key => { - const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), key); - return descriptor && typeof descriptor.value === "function" && key.startsWith("add"); - })); - - // Collect all remove functions of the object in a list - let removeFunctions = Object.getOwnPropertyNames(obj).filter(key => (Object.prototype.hasOwnProperty.call(obj, key) && typeof obj[key] === "function") && key.startsWith("remove")) - // Add all remove functions of the object's prototype to that list - removeFunctions = removeFunctions.concat(Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).filter(key => { - const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), key); - return descriptor && typeof descriptor.value === "function" && key.startsWith("remove"); - })); + /** + * Find a subject class by matching an object's properties/collections against SHACL shapes. + * Queries SHACL links client-side to find a class whose properties contain all required ones. + * @returns The matching class name, or null if no match found. + */ + private async findClassByProperties(obj: object): Promise { + // Extract properties and collections from the object + let properties: string[] = []; + let collections: string[] = []; + const proto = Object.getPrototypeOf(obj); + + if (proto?.__properties) { + properties = Object.keys(proto.__properties); + } else { + properties = Object.keys(obj).filter(key => !Array.isArray((obj as any)[key])); + } - // Collect all add functions of the object in a list - let setCollectionFunctions = Object.getOwnPropertyNames(obj).filter(key => (Object.prototype.hasOwnProperty.call(obj, key) && typeof obj[key] === "function") && key.startsWith("setCollection")) - // Add all add functions of the object's prototype to that list - setCollectionFunctions = setCollectionFunctions.concat(Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).filter(key => { - const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), key); - return descriptor && typeof descriptor.value === "function" && key.startsWith("setCollection"); - })); - // Construct query to find all subject classes that have the given properties and collections - let query = `subject_class(Class, C)` + if (proto?.__collections) { + collections = Object.keys(proto.__collections).filter(key => key !== 'isSubjectInstance'); + } else { + collections = Object.keys(obj).filter(key => Array.isArray((obj as any)[key]) && key !== 'isSubjectInstance'); + } - for(let property of properties) { - query += `, property(C, "${property}")` - } - for(let collection of collections) { - query += `, collection(C, "${collection}")` - } + if (properties.length === 0 && collections.length === 0) { + return null; + } - for(let setFunction of setFunctions) { - // e.g. "setState" -> "state" - let property = setFunction.substring(3) - property = property.charAt(0).toLowerCase() + property.slice(1) - query += `, property_setter(C, "${property}", _)` - } - for(let addFunction of addFunctions) { - query += `, collection_adder(C, "${collectionAdderToName(addFunction)}", _)` + // Single SurrealDB query to find all classes and their properties/collections + const query = `SELECT + in.uri AS shape_source, + predicate, + out.uri AS target + FROM link + WHERE predicate IN ['rdf://type', 'sh://property', 'sh://collection']`; + + const results = await this.querySurrealDB(query); + if (!results || results.length === 0) return null; + + // Build a map of className -> { properties, collections } + const classShapes: Map = new Map(); + + // First pass: find all subject classes + for (const r of results) { + if (r.predicate === 'rdf://type' && r.target === 'ad4m://SubjectClass') { + const source = r.shape_source; + const sepIdx = source.indexOf('://'); + if (sepIdx < 0) continue; + const className = source.substring(sepIdx + 3).split('/').pop(); + if (!className) continue; + classShapes.set(className, { shapeUri: source, properties: [], collections: [] }); } + } - for(let removeFunction of removeFunctions) { - query += `, collection_remover(C, "${collectionRemoverToName(removeFunction)}", _)` + // Second pass: collect properties and collections for each class + for (const r of results) { + if (r.predicate === 'sh://property' || r.predicate === 'sh://collection') { + // Match shape source to class (e.g., "recipe://RecipeShape" -> "Recipe") + for (const [className, shape] of classShapes) { + if (r.shape_source.endsWith(`${className}Shape`)) { + const dotIdx = r.target.lastIndexOf('.'); + if (dotIdx < 0) continue; + const name = r.target.substring(dotIdx + 1); + if (r.predicate === 'sh://property') { + shape.properties.push(name); + } else { + shape.collections.push(name); + } + } + } } + } - for(let setCollectionFunction of setCollectionFunctions) { - query += `, collection_setter(C, "${collectionSetterToName(setCollectionFunction)}", _)` + // Find a class that has all required properties and collections + for (const [className, shape] of classShapes) { + const hasAllProps = properties.every(p => shape.properties.includes(p)); + const hasAllCols = collections.every(c => shape.collections.includes(c)); + if (hasAllProps && hasAllCols) { + return className; } + } - query += "." - result = query - }(obj)) - return result + return null; } /** Returns all subject classes that match the given template object. @@ -1773,13 +2028,31 @@ export class PerspectiveProxy { * @param obj The template object */ async subjectClassesByTemplate(obj: object): Promise { - const query = this.buildQueryFromTemplate(obj); - let result = await this.infer(query) - if(!result) { - return [] - } else { - return result.map(x => x.Class) + // SHACL-based lookup: try property matching first (more precise), fall back to className + try { + const match = await this.findClassByProperties(obj); + if (match) { + return [match]; + } + } catch (e) { + console.warn('subjectClassesByTemplate: property matching failed:', e); + } + + // Fall back to className lookup + try { + // @ts-ignore - className is added dynamically by decorators + const className = obj.className || obj.constructor?.className || obj.constructor?.prototype?.className; + if (className) { + const existingClasses = await this.subjectClasses(); + if (existingClasses.includes(className)) { + return [className]; + } + } + } catch (e) { + console.warn('subjectClassesByTemplate: className lookup failed:', e); } + + return []; } /** Takes a JS class (its constructor) and assumes that it was decorated by @@ -1789,14 +2062,25 @@ export class PerspectiveProxy { * static generateSDNA() function and adds it to the perspective's SDNA. */ async ensureSDNASubjectClass(jsClass: any): Promise { - const subjectClass = await this.subjectClassesByTemplate(new jsClass) - if(subjectClass.length > 0) { - return + // Get the class name from the JS class + const className = jsClass.className || jsClass.prototype?.className || jsClass.name; + + // Note: Duplicate checking is handled on the Rust side in add_sdna + + // Generate SHACL SDNA (Prolog-free) + if (!jsClass.generateSHACL) { + throw new Error(`Class ${jsClass.name} must have generateSHACL(). Use @ModelOptions decorator.`); } - const { name, sdna } = jsClass.generateSDNA(); + // Get SHACL shape (W3C standard + AD4M action definitions) + const { shape } = jsClass.generateSHACL(); + + // Serialize SHACL shape to JSON for Rust backend using SHACLShape.toJSON() + const shaclJson = JSON.stringify(shape.toJSON()); - await this.addSdna(name, sdna, 'subject_class'); + // Pass SHACL JSON to backend (Prolog-free) + // Backend stores SHACL links directly + await this.addSdna(className, '', 'subject_class', shaclJson); } getNeighbourhoodProxy(): NeighbourhoodProxy { diff --git a/core/src/perspectives/PerspectiveResolver.ts b/core/src/perspectives/PerspectiveResolver.ts index 6d040cdcf..785a8b89a 100644 --- a/core/src/perspectives/PerspectiveResolver.ts +++ b/core/src/perspectives/PerspectiveResolver.ts @@ -271,7 +271,7 @@ export default class PerspectiveResolver { } @Mutation(returns => Boolean) - perspectiveAddSdna(@Arg('uuid') uuid: string, @Arg('name') name: string, @Arg('sdnaCode') sdnaCode: string, @Arg('sdnaType') sdnaType: string, @PubSub() pubSub: any): Boolean { + perspectiveAddSdna(@Arg('uuid') uuid: string, @Arg('name') name: string, @Arg('sdnaCode', { nullable: true }) sdnaCode: string, @Arg('sdnaType') sdnaType: string, @Arg('shaclJson', { nullable: true }) shaclJson: string, @PubSub() pubSub: any): Boolean { return true } diff --git a/core/src/shacl/SHACLFlow.test.ts b/core/src/shacl/SHACLFlow.test.ts new file mode 100644 index 000000000..417c1ceb1 --- /dev/null +++ b/core/src/shacl/SHACLFlow.test.ts @@ -0,0 +1,237 @@ +import { SHACLFlow, FlowState, FlowTransition, AD4MAction } from './SHACLFlow'; + +describe('SHACLFlow', () => { + describe('basic construction', () => { + it('creates a flow with name and namespace', () => { + const flow = new SHACLFlow('TODO', 'todo://'); + expect(flow.name).toBe('TODO'); + expect(flow.namespace).toBe('todo://'); + expect(flow.flowUri).toBe('todo://TODOFlow'); + }); + + it('generates correct state URIs', () => { + const flow = new SHACLFlow('TODO', 'todo://'); + expect(flow.stateUri('ready')).toBe('todo://TODO.ready'); + expect(flow.stateUri('done')).toBe('todo://TODO.done'); + }); + + it('generates correct transition URIs', () => { + const flow = new SHACLFlow('TODO', 'todo://'); + expect(flow.transitionUri('ready', 'doing')).toBe('todo://TODO.readyTodoing'); + }); + }); + + describe('state management', () => { + it('adds and retrieves states', () => { + const flow = new SHACLFlow('TODO', 'todo://'); + + flow.addState({ + name: 'ready', + value: 0, + stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + }); + + flow.addState({ + name: 'done', + value: 1, + stateCheck: { predicate: 'todo://state', target: 'todo://done' } + }); + + expect(flow.states.length).toBe(2); + expect(flow.states[0].name).toBe('ready'); + expect(flow.states[1].name).toBe('done'); + }); + }); + + describe('transition management', () => { + it('adds and retrieves transitions', () => { + const flow = new SHACLFlow('TODO', 'todo://'); + + flow.addTransition({ + actionName: 'Complete', + fromState: 'ready', + toState: 'done', + actions: [ + { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }, + { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + ] + }); + + expect(flow.transitions.length).toBe(1); + expect(flow.transitions[0].actionName).toBe('Complete'); + expect(flow.transitions[0].actions.length).toBe(2); + }); + }); + + describe('toLinks()', () => { + it('serializes flow to links', () => { + const flow = new SHACLFlow('TODO', 'todo://'); + flow.flowable = 'any'; + flow.startAction = [ + { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + ]; + + flow.addState({ + name: 'ready', + value: 0, + stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + }); + + flow.addTransition({ + actionName: 'Start', + fromState: 'ready', + toState: 'doing', + actions: [{ action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://doing' }] + }); + + const links = flow.toLinks(); + + // Check flow type link + const typeLink = links.find(l => l.predicate === 'rdf://type' && l.target === 'ad4m://Flow'); + expect(typeLink).toBeDefined(); + expect(typeLink!.source).toBe('todo://TODOFlow'); + + // Check flowable link + const flowableLink = links.find(l => l.predicate === 'ad4m://flowable'); + expect(flowableLink).toBeDefined(); + expect(flowableLink!.target).toBe('ad4m://any'); + + // Check start action link + const startActionLink = links.find(l => l.predicate === 'ad4m://startAction'); + expect(startActionLink).toBeDefined(); + expect(startActionLink!.target).toContain('addLink'); + + // Check state link + const stateLink = links.find(l => l.predicate === 'ad4m://hasState'); + expect(stateLink).toBeDefined(); + expect(stateLink!.target).toBe('todo://TODO.ready'); + + // Check transition link + const transitionLink = links.find(l => l.predicate === 'ad4m://hasTransition'); + expect(transitionLink).toBeDefined(); + }); + }); + + describe('fromLinks()', () => { + it('reconstructs flow from links', () => { + const original = new SHACLFlow('TODO', 'todo://'); + original.flowable = 'any'; + original.startAction = [ + { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + ]; + original.addState({ + name: 'ready', + value: 0, + stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + }); + original.addState({ + name: 'done', + value: 1, + stateCheck: { predicate: 'todo://state', target: 'todo://done' } + }); + original.addTransition({ + actionName: 'Complete', + fromState: 'ready', + toState: 'done', + actions: [{ action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }] + }); + + const links = original.toLinks(); + const reconstructed = SHACLFlow.fromLinks(links, 'todo://TODOFlow'); + + expect(reconstructed.name).toBe('TODO'); + expect(reconstructed.namespace).toBe('todo://'); + expect(reconstructed.flowable).toBe('any'); + expect(reconstructed.startAction.length).toBe(1); + expect(reconstructed.states.length).toBe(2); + expect(reconstructed.transitions.length).toBe(1); + expect(reconstructed.transitions[0].actionName).toBe('Complete'); + }); + }); + + describe('JSON serialization', () => { + it('converts to and from JSON', () => { + const original = new SHACLFlow('TODO', 'todo://'); + original.addState({ + name: 'ready', + value: 0, + stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + }); + original.addTransition({ + actionName: 'Start', + fromState: 'ready', + toState: 'doing', + actions: [] + }); + + const json = original.toJSON(); + const reconstructed = SHACLFlow.fromJSON(json); + + expect(reconstructed.name).toBe('TODO'); + expect(reconstructed.states.length).toBe(1); + expect(reconstructed.transitions.length).toBe(1); + }); + }); + + describe('full TODO example', () => { + it('creates complete TODO flow matching Prolog example', () => { + const flow = new SHACLFlow('TODO', 'todo://'); + flow.flowable = 'any'; + + // Start action - renders expression as TODO in 'ready' state + flow.startAction = [ + { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + ]; + + // Three states + flow.addState({ + name: 'ready', + value: 0, + stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + }); + flow.addState({ + name: 'doing', + value: 0.5, + stateCheck: { predicate: 'todo://state', target: 'todo://doing' } + }); + flow.addState({ + name: 'done', + value: 1, + stateCheck: { predicate: 'todo://state', target: 'todo://done' } + }); + + // Transitions + flow.addTransition({ + actionName: 'Start', + fromState: 'ready', + toState: 'doing', + actions: [ + { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://doing' }, + { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + ] + }); + flow.addTransition({ + actionName: 'Finish', + fromState: 'doing', + toState: 'done', + actions: [ + { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }, + { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://doing' } + ] + }); + + // Verify structure + expect(flow.states.length).toBe(3); + expect(flow.transitions.length).toBe(2); + + // Verify links generation + const links = flow.toLinks(); + expect(links.length).toBeGreaterThan(15); // Flow + 3 states + 2 transitions = many links + + // Verify round-trip + const reconstructed = SHACLFlow.fromLinks(links, flow.flowUri); + expect(reconstructed.states.length).toBe(3); + expect(reconstructed.transitions.length).toBe(2); + }); + }); +}); diff --git a/core/src/shacl/SHACLFlow.ts b/core/src/shacl/SHACLFlow.ts new file mode 100644 index 000000000..3bc081493 --- /dev/null +++ b/core/src/shacl/SHACLFlow.ts @@ -0,0 +1,503 @@ +import { Link } from "../links/Links"; +import { Literal } from "../Literal"; +import { AD4MAction } from "./SHACLShape"; + +// Re-export AD4MAction for consumers who import from SHACLFlow +export { AD4MAction }; + +/** + * Link pattern for state detection + * Used to check if an expression is in a particular state + */ +export interface LinkPattern { + /** Optional source pattern (if omitted, uses the expression address) */ + source?: string; + /** Required predicate to match */ + predicate: string; + /** Required target value to match */ + target: string; +} + +/** + * Flow State definition + * Represents a single state in the flow state machine + */ +export interface FlowState { + /** State name (e.g., "ready", "doing", "done") */ + name: string; + /** Numeric state value for ordering (e.g., 0, 0.5, 1) */ + value: number; + /** Link pattern that indicates this state */ + stateCheck: LinkPattern; +} + +/** + * Flow Transition definition + * Represents a transition between two states + */ +export interface FlowTransition { + /** Name of this action (shown to users, e.g., "Start", "Finish") */ + actionName: string; + /** State to transition from */ + fromState: string; + /** State to transition to */ + toState: string; + /** Actions to execute for this transition */ + actions: AD4MAction[]; +} + +/** + * Flowable condition - determines which expressions can enter this flow + * "any" means all expressions can start this flow + * Otherwise, a link pattern to check + */ +export type FlowableCondition = "any" | LinkPattern; + +/** + * SHACL Flow - represents a state machine for AD4M expressions + * + * Flows define: + * - Which expressions can enter the flow (flowable condition) + * - What states exist and how to detect them (via link patterns) + * - How to transition between states (via actions) + * + * @example + * ```typescript + * const todoFlow = new SHACLFlow('todo://TODO', 'todo://'); + * + * // Any expression can become a TODO + * todoFlow.flowable = 'any'; + * + * // Define states + * todoFlow.addState({ + * name: 'ready', + * value: 0, + * stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + * }); + * todoFlow.addState({ + * name: 'doing', + * value: 0.5, + * stateCheck: { predicate: 'todo://state', target: 'todo://doing' } + * }); + * todoFlow.addState({ + * name: 'done', + * value: 1, + * stateCheck: { predicate: 'todo://state', target: 'todo://done' } + * }); + * + * // Define start action + * todoFlow.startAction = [{ + * action: 'addLink', + * source: 'this', + * predicate: 'todo://state', + * target: 'todo://ready' + * }]; + * + * // Define transitions + * todoFlow.addTransition({ + * actionName: 'Start', + * fromState: 'ready', + * toState: 'doing', + * actions: [ + * { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://doing' }, + * { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + * ] + * }); + * + * // Store in perspective + * await perspective.addFlow('TODO', todoFlow); + * ``` + */ +export class SHACLFlow { + /** Flow name (e.g., "TODO") */ + public name: string; + + /** Namespace for generated URIs */ + public namespace: string; + + /** Condition for which expressions can start this flow */ + public flowable: FlowableCondition = "any"; + + /** Actions to execute when starting the flow */ + public startAction: AD4MAction[] = []; + + /** States in this flow */ + private _states: FlowState[] = []; + + /** Transitions between states */ + private _transitions: FlowTransition[] = []; + + /** + * Create a new SHACL Flow + * @param name - Flow name (e.g., "TODO") + * @param namespace - Namespace for URIs (e.g., "todo://") + */ + constructor(name: string, namespace: string) { + this.name = name; + this.namespace = namespace; + } + + /** Get all states */ + get states(): FlowState[] { + return [...this._states]; + } + + /** Get all transitions */ + get transitions(): FlowTransition[] { + return [...this._transitions]; + } + + /** + * Add a state to the flow + * @param state - State definition + */ + addState(state: FlowState): void { + this._states.push(state); + } + + /** + * Add a transition to the flow + * @param transition - Transition definition + */ + addTransition(transition: FlowTransition): void { + this._transitions.push(transition); + } + + /** + * Get the flow shape URI + */ + get flowUri(): string { + return `${this.namespace}${this.name}Flow`; + } + + /** + * Get a state URI + */ + stateUri(stateName: string): string { + return `${this.namespace}${this.name}.${stateName}`; + } + + /** + * Get a transition URI + */ + transitionUri(fromState: string, toState: string): string { + return `${this.namespace}${this.name}.${fromState}To${toState}`; + } + + /** + * Serialize the flow to AD4M links + * These links can be stored in a perspective and queried via SurrealDB + * + * @returns Array of Link objects representing the flow + */ + toLinks(): Link[] { + const links: Link[] = []; + const flowUri = this.flowUri; + + // Flow type + links.push({ + source: flowUri, + predicate: "rdf://type", + target: "ad4m://Flow" + }); + + // Flow name + links.push({ + source: flowUri, + predicate: "ad4m://flowName", + target: Literal.from(this.name).toUrl() + }); + + // Flowable condition + if (this.flowable === "any") { + links.push({ + source: flowUri, + predicate: "ad4m://flowable", + target: "ad4m://any" + }); + } else { + links.push({ + source: flowUri, + predicate: "ad4m://flowable", + target: `literal://string:${encodeURIComponent(JSON.stringify(this.flowable))}` + }); + } + + // Start action + if (this.startAction.length > 0) { + links.push({ + source: flowUri, + predicate: "ad4m://startAction", + target: `literal://string:${encodeURIComponent(JSON.stringify(this.startAction))}` + }); + } + + // States + for (const state of this._states) { + const stateUri = this.stateUri(state.name); + + // Link flow to state + links.push({ + source: flowUri, + predicate: "ad4m://hasState", + target: stateUri + }); + + // State type + links.push({ + source: stateUri, + predicate: "rdf://type", + target: "ad4m://FlowState" + }); + + // State name + links.push({ + source: stateUri, + predicate: "ad4m://stateName", + target: Literal.from(state.name).toUrl() + }); + + // State value + links.push({ + source: stateUri, + predicate: "ad4m://stateValue", + target: Literal.from(state.value).toUrl() + }); + + // State check pattern + links.push({ + source: stateUri, + predicate: "ad4m://stateCheck", + target: `literal://string:${encodeURIComponent(JSON.stringify(state.stateCheck))}` + }); + } + + // Transitions + for (const transition of this._transitions) { + const transitionUri = this.transitionUri(transition.fromState, transition.toState); + const fromStateUri = this.stateUri(transition.fromState); + const toStateUri = this.stateUri(transition.toState); + + // Link flow to transition + links.push({ + source: flowUri, + predicate: "ad4m://hasTransition", + target: transitionUri + }); + + // Transition type + links.push({ + source: transitionUri, + predicate: "rdf://type", + target: "ad4m://FlowTransition" + }); + + // Action name + links.push({ + source: transitionUri, + predicate: "ad4m://actionName", + target: Literal.from(transition.actionName).toUrl() + }); + + // From state + links.push({ + source: transitionUri, + predicate: "ad4m://fromState", + target: fromStateUri + }); + + // To state + links.push({ + source: transitionUri, + predicate: "ad4m://toState", + target: toStateUri + }); + + // Transition actions + links.push({ + source: transitionUri, + predicate: "ad4m://transitionActions", + target: `literal://string:${encodeURIComponent(JSON.stringify(transition.actions))}` + }); + } + + return links; + } + + /** + * Reconstruct a SHACLFlow from links + * + * @param links - Array of links containing the flow definition + * @param flowUri - The URI of the flow to reconstruct + * @returns Reconstructed SHACLFlow + */ + static fromLinks(links: Link[], flowUri: string): SHACLFlow { + // Extract namespace and name from flowUri + // Format: {namespace}{Name}Flow + const flowSuffix = "Flow"; + if (!flowUri.endsWith(flowSuffix)) { + throw new Error(`Invalid flow URI: ${flowUri} (must end with 'Flow')`); + } + + const withoutSuffix = flowUri.slice(0, -flowSuffix.length); + const lastSlashOrColon = Math.max( + withoutSuffix.lastIndexOf('/'), + withoutSuffix.lastIndexOf(':') + ); + + const namespace = withoutSuffix.slice(0, lastSlashOrColon + 1); + const name = withoutSuffix.slice(lastSlashOrColon + 1); + + const flow = new SHACLFlow(name, namespace); + + // Find flowable condition + const flowableLink = links.find(l => + l.source === flowUri && l.predicate === "ad4m://flowable" + ); + if (flowableLink) { + if (flowableLink.target === "ad4m://any") { + flow.flowable = "any"; + } else { + try { + const jsonStr = flowableLink.target.replace('literal://string:', ''); + flow.flowable = JSON.parse(decodeURIComponent(jsonStr)); + } catch { + flow.flowable = "any"; + } + } + } + + // Find start action + const startActionLink = links.find(l => + l.source === flowUri && l.predicate === "ad4m://startAction" + ); + if (startActionLink) { + try { + const jsonStr = startActionLink.target.replace('literal://string:', ''); + flow.startAction = JSON.parse(decodeURIComponent(jsonStr)); + } catch { + // Ignore parse errors + } + } + + // Find states + const stateLinks = links.filter(l => + l.source === flowUri && l.predicate === "ad4m://hasState" + ); + + // Build a map from state URI to state name for later lookup + const stateUriToName = new Map(); + + for (const stateLink of stateLinks) { + const stateUri = stateLink.target; + + // Get state name + const nameLink = links.find(l => + l.source === stateUri && l.predicate === "ad4m://stateName" + ); + const stateName = nameLink ? Literal.fromUrl(nameLink.target).get() as string : ""; + + // Store mapping for transition lookup + stateUriToName.set(stateUri, stateName); + + // Get state value + const valueLink = links.find(l => + l.source === stateUri && l.predicate === "ad4m://stateValue" + ); + const stateValue = valueLink ? Literal.fromUrl(valueLink.target).get() as number : 0; + + // Get state check + const checkLink = links.find(l => + l.source === stateUri && l.predicate === "ad4m://stateCheck" + ); + let stateCheck: LinkPattern = { predicate: "", target: "" }; + if (checkLink) { + try { + const jsonStr = checkLink.target.replace('literal://string:', ''); + stateCheck = JSON.parse(decodeURIComponent(jsonStr)); + } catch { + // Ignore parse errors + } + } + + flow.addState({ name: stateName, value: stateValue, stateCheck }); + } + + // Find transitions + const transitionLinks = links.filter(l => + l.source === flowUri && l.predicate === "ad4m://hasTransition" + ); + + for (const transitionLink of transitionLinks) { + const transitionUri = transitionLink.target; + + // Get action name + const actionNameLink = links.find(l => + l.source === transitionUri && l.predicate === "ad4m://actionName" + ); + const actionName = actionNameLink ? Literal.fromUrl(actionNameLink.target).get() as string : ""; + + // Get from state + const fromStateLink = links.find(l => + l.source === transitionUri && l.predicate === "ad4m://fromState" + ); + const fromStateUri = fromStateLink?.target || ""; + const fromState = stateUriToName.get(fromStateUri) || ""; + + // Get to state + const toStateLink = links.find(l => + l.source === transitionUri && l.predicate === "ad4m://toState" + ); + const toStateUri = toStateLink?.target || ""; + const toState = stateUriToName.get(toStateUri) || ""; + + // Get actions + const actionsLink = links.find(l => + l.source === transitionUri && l.predicate === "ad4m://transitionActions" + ); + let actions: AD4MAction[] = []; + if (actionsLink) { + try { + const jsonStr = actionsLink.target.replace('literal://string:', ''); + actions = JSON.parse(decodeURIComponent(jsonStr)); + } catch { + // Ignore parse errors + } + } + + flow.addTransition({ actionName, fromState, toState, actions }); + } + + return flow; + } + + /** + * Convert to JSON representation + */ + toJSON(): object { + return { + name: this.name, + namespace: this.namespace, + flowable: this.flowable, + startAction: this.startAction, + states: this._states, + transitions: this._transitions + }; + } + + /** + * Create from JSON representation + */ + static fromJSON(json: any): SHACLFlow { + const flow = new SHACLFlow(json.name, json.namespace); + flow.flowable = json.flowable || "any"; + flow.startAction = json.startAction || []; + for (const state of json.states || []) { + flow.addState(state); + } + for (const transition of json.transitions || []) { + flow.addTransition(transition); + } + return flow; + } +} diff --git a/core/src/shacl/SHACLShape.test.ts b/core/src/shacl/SHACLShape.test.ts new file mode 100644 index 000000000..0432da6a9 --- /dev/null +++ b/core/src/shacl/SHACLShape.test.ts @@ -0,0 +1,400 @@ +import { SHACLShape, SHACLPropertyShape, AD4MAction } from './SHACLShape'; + +describe('SHACLShape', () => { + describe('toLinks()', () => { + it('creates basic shape links', () => { + const shape = new SHACLShape('recipe://Recipe'); + const links = shape.toLinks(); + + // Should have targetClass link + const targetClassLink = links.find(l => l.predicate === 'sh://targetClass'); + expect(targetClassLink).toBeDefined(); + expect(targetClassLink!.source).toBe('recipe://RecipeShape'); + expect(targetClassLink!.target).toBe('recipe://Recipe'); + }); + + it('creates property shape links with named URIs', () => { + const shape = new SHACLShape('recipe://Recipe'); + const prop: SHACLPropertyShape = { + name: 'name', + path: 'recipe://name', + datatype: 'xsd:string', + minCount: 1, + maxCount: 1, + }; + shape.addProperty(prop); + const links = shape.toLinks(); + + // Property shape should use named URI + const propLink = links.find(l => l.predicate === 'sh://property'); + expect(propLink).toBeDefined(); + expect(propLink!.target).toBe('recipe://Recipe.name'); // Named URI, not blank node + + // Path link + const pathLink = links.find(l => + l.source === 'recipe://Recipe.name' && l.predicate === 'sh://path' + ); + expect(pathLink).toBeDefined(); + expect(pathLink!.target).toBe('recipe://name'); + + // Datatype link + const datatypeLink = links.find(l => + l.source === 'recipe://Recipe.name' && l.predicate === 'sh://datatype' + ); + expect(datatypeLink).toBeDefined(); + expect(datatypeLink!.target).toBe('xsd:string'); + + // Cardinality links + const minCountLink = links.find(l => + l.source === 'recipe://Recipe.name' && l.predicate === 'sh://minCount' + ); + expect(minCountLink).toBeDefined(); + expect(minCountLink!.target).toContain('1'); + + const maxCountLink = links.find(l => + l.source === 'recipe://Recipe.name' && l.predicate === 'sh://maxCount' + ); + expect(maxCountLink).toBeDefined(); + expect(maxCountLink!.target).toContain('1'); + }); + + it('creates action links', () => { + const shape = new SHACLShape('recipe://Recipe'); + const setterAction: AD4MAction = { + action: 'addLink', + source: 'this', + predicate: 'recipe://name', + target: 'value', + }; + const prop: SHACLPropertyShape = { + name: 'title', + path: 'recipe://title', + setter: [setterAction], + }; + shape.addProperty(prop); + const links = shape.toLinks(); + + // Setter action link + const setterLink = links.find(l => + l.source === 'recipe://Recipe.title' && l.predicate === 'ad4m://setter' + ); + expect(setterLink).toBeDefined(); + expect(setterLink!.target).toContain('addLink'); + }); + + it('includes constructor and destructor actions', () => { + const shape = new SHACLShape('recipe://Recipe'); + shape.constructor_actions = [{ + action: 'addLink', + source: 'this', + predicate: 'ad4m://type', + target: 'recipe://Recipe', + }]; + shape.destructor_actions = [{ + action: 'removeLink', + source: 'this', + predicate: 'ad4m://type', + target: 'recipe://Recipe', + }]; + const links = shape.toLinks(); + + const constructorLink = links.find(l => l.predicate === 'ad4m://constructor'); + expect(constructorLink).toBeDefined(); + expect(constructorLink!.target).toContain('addLink'); + + const destructorLink = links.find(l => l.predicate === 'ad4m://destructor'); + expect(destructorLink).toBeDefined(); + expect(destructorLink!.target).toContain('removeLink'); + }); + }); + + describe('fromLinks()', () => { + it('reconstructs shape from links', () => { + const originalShape = new SHACLShape('recipe://Recipe'); + originalShape.addProperty({ + name: 'name', + path: 'recipe://name', + datatype: 'xsd:string', + minCount: 1, + }); + + const links = originalShape.toLinks(); + const reconstructed = SHACLShape.fromLinks(links, 'recipe://RecipeShape'); + + expect(reconstructed.targetClass).toBe('recipe://Recipe'); + expect(reconstructed.properties.length).toBe(1); + expect(reconstructed.properties[0].path).toBe('recipe://name'); + expect(reconstructed.properties[0].datatype).toBe('xsd:string'); + expect(reconstructed.properties[0].minCount).toBe(1); + }); + + it('handles multiple properties', () => { + const originalShape = new SHACLShape('recipe://Recipe'); + originalShape.addProperty({ + name: 'name', + path: 'recipe://name', + datatype: 'xsd:string', + }); + originalShape.addProperty({ + name: 'servings', + path: 'recipe://servings', + datatype: 'xsd:integer', + }); + + const links = originalShape.toLinks(); + const reconstructed = SHACLShape.fromLinks(links, 'recipe://RecipeShape'); + + expect(reconstructed.properties.length).toBe(2); + const nameProp = reconstructed.properties.find(p => p.path === 'recipe://name'); + const servingsProp = reconstructed.properties.find(p => p.path === 'recipe://servings'); + expect(nameProp).toBeDefined(); + expect(servingsProp).toBeDefined(); + expect(nameProp!.datatype).toBe('xsd:string'); + expect(servingsProp!.datatype).toBe('xsd:integer'); + }); + + it('reconstructs action arrays', () => { + const originalShape = new SHACLShape('recipe://Recipe'); + const setterAction: AD4MAction = { + action: 'addLink', + source: 'this', + predicate: 'recipe://name', + target: 'value', + }; + originalShape.addProperty({ + name: 'name', + path: 'recipe://name', + setter: [setterAction], + }); + + const links = originalShape.toLinks(); + const reconstructed = SHACLShape.fromLinks(links, 'recipe://RecipeShape'); + + expect(reconstructed.properties[0].setter).toBeDefined(); + expect(reconstructed.properties[0].setter!.length).toBe(1); + expect(reconstructed.properties[0].setter![0].action).toBe('addLink'); + }); + }); + + describe('round-trip serialization', () => { + it('preserves all property attributes', () => { + const original = new SHACLShape('test://Model'); + original.addProperty({ + name: 'field', + path: 'test://field', + datatype: 'xsd:string', + nodeKind: 'Literal', + minCount: 0, + maxCount: 5, + pattern: '^[a-z]+$', + minInclusive: 10, + maxInclusive: 100, + hasValue: 'expectedValue', + resolveLanguage: 'test://language', + local: true, + writable: true, + setter: [{ action: 'addLink', source: 'this', predicate: 'test://field', target: 'value' }], + adder: [{ action: 'addLink', source: 'this', predicate: 'test://items', target: 'value' }], + remover: [{ action: 'removeLink', source: 'this', predicate: 'test://items', target: 'value' }], + }); + + const links = original.toLinks(); + const reconstructed = SHACLShape.fromLinks(links, 'test://ModelShape'); + + const prop = reconstructed.properties[0]; + expect(prop.path).toBe('test://field'); + expect(prop.datatype).toBe('xsd:string'); + expect(prop.nodeKind).toBe('Literal'); + expect(prop.minCount).toBe(0); + expect(prop.maxCount).toBe(5); + expect(prop.pattern).toBe('^[a-z]+$'); + expect(prop.minInclusive).toBe(10); + expect(prop.maxInclusive).toBe(100); + expect(prop.hasValue).toBe('expectedValue'); + expect(prop.resolveLanguage).toBe('test://language'); + expect(prop.local).toBe(true); + expect(prop.writable).toBe(true); + expect(prop.setter).toBeDefined(); + expect(prop.adder).toBeDefined(); + expect(prop.remover).toBeDefined(); + }); + + it('preserves constructor and destructor actions', () => { + const original = new SHACLShape('test://Model'); + original.constructor_actions = [ + { action: 'addLink', source: 'this', predicate: 'rdf://type', target: 'test://Model' } + ]; + original.destructor_actions = [ + { action: 'removeLink', source: 'this', predicate: 'rdf://type', target: 'test://Model' } + ]; + + const links = original.toLinks(); + const reconstructed = SHACLShape.fromLinks(links, 'test://ModelShape'); + + expect(reconstructed.constructor_actions).toBeDefined(); + expect(reconstructed.constructor_actions!.length).toBe(1); + expect(reconstructed.constructor_actions![0].action).toBe('addLink'); + + expect(reconstructed.destructor_actions).toBeDefined(); + expect(reconstructed.destructor_actions!.length).toBe(1); + expect(reconstructed.destructor_actions![0].action).toBe('removeLink'); + }); + }); + + describe('edge cases', () => { + it('handles empty shape', () => { + const shape = new SHACLShape('test://Empty'); + const links = shape.toLinks(); + + expect(links.length).toBeGreaterThanOrEqual(1); // At least targetClass link + + const reconstructed = SHACLShape.fromLinks(links, 'test://EmptyShape'); + expect(reconstructed.targetClass).toBe('test://Empty'); + expect(reconstructed.properties.length).toBe(0); + }); + + it('handles URI with hash fragment', () => { + const shape = new SHACLShape('https://example.com/vocab#Recipe'); + const links = shape.toLinks(); + + const targetClassLink = links.find(l => l.predicate === 'sh://targetClass'); + expect(targetClassLink!.source).toBe('https://example.com/vocab#RecipeShape'); + }); + + it('falls back to blank nodes when property has no name', () => { + const shape = new SHACLShape('test://Model'); + shape.addProperty({ + path: 'test://unnamed', + // No name property + }); + const links = shape.toLinks(); + + const propLink = links.find(l => l.predicate === 'sh://property'); + expect(propLink).toBeDefined(); + // Should use blank node format when no name provided + expect(propLink!.target).toMatch(/_:propShape\d+|test:\/\/Model\./); + }); + }); + + describe('toJSON/fromJSON', () => { + it('serializes and deserializes basic shape', () => { + const original = new SHACLShape('recipe://Recipe'); + original.addProperty({ + name: 'name', + path: 'recipe://name', + datatype: 'xsd:string', + minCount: 1, + }); + + const json = original.toJSON(); + const reconstructed = SHACLShape.fromJSON(json); + + expect(reconstructed.targetClass).toBe('recipe://Recipe'); + expect(reconstructed.properties.length).toBe(1); + expect(reconstructed.properties[0].name).toBe('name'); + expect(reconstructed.properties[0].path).toBe('recipe://name'); + expect(reconstructed.properties[0].datatype).toBe('xsd:string'); + expect(reconstructed.properties[0].minCount).toBe(1); + }); + + it('preserves nodeShapeUri in round-trip', () => { + const original = new SHACLShape('custom://CustomShape', 'recipe://Recipe'); + + const json = original.toJSON(); + const reconstructed = SHACLShape.fromJSON(json); + + expect(reconstructed.nodeShapeUri).toBe('custom://CustomShape'); + expect(reconstructed.targetClass).toBe('recipe://Recipe'); + }); + + it('preserves minInclusive and maxInclusive', () => { + const original = new SHACLShape('test://Model'); + original.addProperty({ + name: 'rating', + path: 'test://rating', + datatype: 'xsd:integer', + minInclusive: 1, + maxInclusive: 5, + }); + + const json = original.toJSON(); + const reconstructed = SHACLShape.fromJSON(json); + + expect(reconstructed.properties[0].minInclusive).toBe(1); + expect(reconstructed.properties[0].maxInclusive).toBe(5); + }); + + it('preserves resolveLanguage', () => { + const original = new SHACLShape('test://Model'); + original.addProperty({ + name: 'content', + path: 'test://content', + resolveLanguage: 'literal', + }); + + const json = original.toJSON(); + const reconstructed = SHACLShape.fromJSON(json); + + expect(reconstructed.properties[0].resolveLanguage).toBe('literal'); + }); + + it('preserves constructor and destructor actions', () => { + const original = new SHACLShape('test://Model'); + original.constructor_actions = [ + { action: 'addLink', source: 'this', predicate: 'rdf://type', target: 'test://Model' } + ]; + original.destructor_actions = [ + { action: 'removeLink', source: 'this', predicate: 'rdf://type', target: 'test://Model' } + ]; + + const json = original.toJSON(); + const reconstructed = SHACLShape.fromJSON(json); + + expect(reconstructed.constructor_actions).toEqual(original.constructor_actions); + expect(reconstructed.destructor_actions).toEqual(original.destructor_actions); + }); + + it('preserves all property attributes', () => { + const original = new SHACLShape('test://Model'); + original.addProperty({ + name: 'field', + path: 'test://field', + datatype: 'xsd:string', + nodeKind: 'Literal', + minCount: 0, + maxCount: 5, + minInclusive: 0, + maxInclusive: 100, + pattern: '^[a-z]+$', + hasValue: 'default', + local: true, + writable: true, + resolveLanguage: 'literal', + setter: [{ action: 'addLink', source: 'this', predicate: 'test://field', target: 'value' }], + adder: [{ action: 'addLink', source: 'this', predicate: 'test://items', target: 'value' }], + remover: [{ action: 'removeLink', source: 'this', predicate: 'test://items', target: 'value' }], + }); + + const json = original.toJSON(); + const reconstructed = SHACLShape.fromJSON(json); + + const prop = reconstructed.properties[0]; + expect(prop.name).toBe('field'); + expect(prop.path).toBe('test://field'); + expect(prop.datatype).toBe('xsd:string'); + expect(prop.nodeKind).toBe('Literal'); + expect(prop.minCount).toBe(0); + expect(prop.maxCount).toBe(5); + expect(prop.minInclusive).toBe(0); + expect(prop.maxInclusive).toBe(100); + expect(prop.pattern).toBe('^[a-z]+$'); + expect(prop.hasValue).toBe('default'); + expect(prop.local).toBe(true); + expect(prop.writable).toBe(true); + expect(prop.resolveLanguage).toBe('literal'); + expect(prop.setter).toEqual(original.properties[0].setter); + expect(prop.adder).toEqual(original.properties[0].adder); + expect(prop.remover).toEqual(original.properties[0].remover); + }); + }); +}); diff --git a/core/src/shacl/SHACLShape.ts b/core/src/shacl/SHACLShape.ts new file mode 100644 index 000000000..e8c104f02 --- /dev/null +++ b/core/src/shacl/SHACLShape.ts @@ -0,0 +1,755 @@ +import { Link } from "../links/Links"; + +/** + * Extract namespace from a URI + * Examples: + * - "recipe://name" -> "recipe://" + * - "https://example.com/vocab#term" -> "https://example.com/vocab#" + * - "https://example.com/vocab/term" -> "https://example.com/vocab/" + * - "recipe:Recipe" -> "recipe:" + */ +function extractNamespace(uri: string): string { + // Handle hash fragments first (highest priority) + const hashIndex = uri.lastIndexOf('#'); + if (hashIndex !== -1) { + return uri.substring(0, hashIndex + 1); + } + + // Handle protocol-style URIs with paths + const protocolMatch = uri.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)(.*)$/); + if (protocolMatch) { + const afterScheme = protocolMatch[2]; + const lastSlash = afterScheme.lastIndexOf('/'); + if (lastSlash !== -1) { + // Has path segments - namespace includes up to last slash + return protocolMatch[1] + afterScheme.substring(0, lastSlash + 1); + } + // Simple protocol URI without path (e.g., "recipe://name") + return protocolMatch[1]; + } + + // Handle colon-separated (namespace:localName) + const colonMatch = uri.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:)/); + if (colonMatch) { + return colonMatch[1]; + } + + // Fallback: no clear namespace + return ''; +} + +/** + * Escape special characters for Turtle string literals + * Handles backslashes, quotes, newlines, and other control characters + */ +function escapeTurtleString(value: string): string { + return value + .replace(/\\/g, '\\\\') // Backslash must be first + .replace(/"/g, '\\"') // Double quotes + .replace(/\n/g, '\\n') // Newlines + .replace(/\r/g, '\\r') // Carriage returns + .replace(/\t/g, '\\t') // Tabs + .replace(/\b/g, '\\b') // Backspace + .replace(/\f/g, '\\f'); // Form feed +} + +/** + * Extract local name from a URI + * Examples: + * - "recipe://name" -> "name" + * - "https://example.com/vocab#term" -> "term" + * - "https://example.com/vocab/term" -> "term" + * - "recipe:Recipe" -> "Recipe" + */ +function extractLocalName(uri: string): string { + // Handle hash fragments + const hashIndex = uri.lastIndexOf('#'); + if (hashIndex !== -1) { + return uri.substring(hashIndex + 1); + } + + // Handle slash-based namespaces + const lastSlash = uri.lastIndexOf('/'); + if (lastSlash !== -1 && lastSlash < uri.length - 1) { + return uri.substring(lastSlash + 1); + } + + // Handle colon-separated + const colonMatch = uri.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:(.+)$/); + if (colonMatch) { + return colonMatch[1]; + } + + // Fallback: entire URI + return uri; +} + +/** + * AD4M Action - represents a link operation + */ +export interface AD4MAction { + action: string; + source: string; + predicate: string; + target: string; + local?: boolean; +} + +/** + * SHACL Property Shape + * Represents constraints on a single property path + */ +export interface SHACLPropertyShape { + /** Property name (e.g., "name", "ingredients") - used for generating named URIs */ + name?: string; + + /** The property path (predicate URI) */ + path: string; + + /** Expected datatype (e.g., xsd:string, xsd:integer) */ + datatype?: string; + + /** Node kind constraint (IRI, Literal, BlankNode) */ + nodeKind?: 'IRI' | 'Literal' | 'BlankNode'; + + /** Minimum cardinality (required if >= 1) */ + minCount?: number; + + /** Maximum cardinality (single-valued if 1, omit for collections) */ + maxCount?: number; + + /** Regex pattern for string validation */ + pattern?: string; + + /** Minimum value (inclusive) for numeric properties */ + minInclusive?: number; + + /** Maximum value (inclusive) for numeric properties */ + maxInclusive?: number; + + /** Fixed value constraint (for Flag properties) */ + hasValue?: string; + + /** AD4M-specific: Local-only property */ + local?: boolean; + + /** AD4M-specific: Writable property */ + writable?: boolean; + + /** AD4M-specific: Language to resolve property values through */ + resolveLanguage?: string; + + /** AD4M-specific: Setter action for this property */ + setter?: AD4MAction[]; + + /** AD4M-specific: Adder action for collection properties */ + adder?: AD4MAction[]; + + /** AD4M-specific: Remover action for collection properties */ + remover?: AD4MAction[]; +} + +/** + * SHACL Node Shape + * Defines constraints for instances of a class + */ +export class SHACLShape { + /** URI of this shape (e.g., recipe:RecipeShape) */ + nodeShapeUri: string; + + /** Target class this shape applies to (e.g., recipe:Recipe) */ + targetClass?: string; + + /** Property constraints */ + properties: SHACLPropertyShape[]; + + /** AD4M-specific: Constructor actions for creating instances */ + constructor_actions?: AD4MAction[]; + + /** AD4M-specific: Destructor actions for removing instances */ + destructor_actions?: AD4MAction[]; + + /** + * Create a new SHACL Shape + * @param targetClassOrShapeUri - If one argument: the target class (shape URI auto-derived as {class}Shape) + * If two arguments: first is shape URI, second is target class + * @param targetClass - Optional target class when first arg is shape URI + */ + constructor(targetClassOrShapeUri: string, targetClass?: string) { + if (targetClass !== undefined) { + // Two arguments: explicit shape URI and target class + this.nodeShapeUri = targetClassOrShapeUri; + this.targetClass = targetClass; + } else { + // One argument: derive shape URI from target class + this.targetClass = targetClassOrShapeUri; + // Derive shape URI by appending "Shape" to the local name + const namespace = extractNamespace(targetClassOrShapeUri); + const localName = extractLocalName(targetClassOrShapeUri); + this.nodeShapeUri = `${namespace}${localName}Shape`; + } + this.properties = []; + } + + /** + * Add a property constraint to this shape + */ + addProperty(prop: SHACLPropertyShape): void { + this.properties.push(prop); + } + + /** + * Set constructor actions for this shape + */ + setConstructorActions(actions: AD4MAction[]): void { + this.constructor_actions = actions; + } + + /** + * Set destructor actions for this shape + */ + setDestructorActions(actions: AD4MAction[]): void { + this.destructor_actions = actions; + } + + /** + * Serialize shape to Turtle (RDF) format + */ + toTurtle(): string { + let turtle = `@prefix sh: .\n`; + turtle += `@prefix xsd: .\n`; + turtle += `@prefix rdf: .\n`; + turtle += `@prefix ad4m: .\n\n`; + + turtle += `<${this.nodeShapeUri}>\n`; + turtle += ` a sh:NodeShape ;\n`; + + if (this.targetClass) { + turtle += ` sh:targetClass <${this.targetClass}> ;\n`; + } + + // Add property shapes + for (let i = 0; i < this.properties.length; i++) { + const prop = this.properties[i]; + const isLast = i === this.properties.length - 1; + + turtle += ` sh:property [\n`; + turtle += ` sh:path <${prop.path}> ;\n`; + + if (prop.datatype) { + turtle += ` sh:datatype <${prop.datatype}> ;\n`; + } + + if (prop.nodeKind) { + turtle += ` sh:nodeKind sh:${prop.nodeKind} ;\n`; + } + + if (prop.minCount !== undefined) { + turtle += ` sh:minCount ${prop.minCount} ;\n`; + } + + if (prop.maxCount !== undefined) { + turtle += ` sh:maxCount ${prop.maxCount} ;\n`; + } + + if (prop.pattern) { + turtle += ` sh:pattern "${escapeTurtleString(prop.pattern)}" ;\n`; + } + + if (prop.minInclusive !== undefined) { + turtle += ` sh:minInclusive ${prop.minInclusive} ;\n`; + } + + if (prop.maxInclusive !== undefined) { + turtle += ` sh:maxInclusive ${prop.maxInclusive} ;\n`; + } + + if (prop.hasValue) { + turtle += ` sh:hasValue "${escapeTurtleString(prop.hasValue)}" ;\n`; + } + + // AD4M-specific metadata + if (prop.local !== undefined) { + turtle += ` ad4m:local ${prop.local} ;\n`; + } + + if (prop.writable !== undefined) { + turtle += ` ad4m:writable ${prop.writable} ;\n`; + } + + // Remove trailing semicolon and close bracket + turtle = turtle.slice(0, -2) + '\n'; + turtle += isLast ? ` ] .\n` : ` ] ;\n`; + } + + return turtle; + } + + /** + * Serialize shape to AD4M Links (RDF triples) + * Stores the shape as a graph of links in a Perspective + */ + toLinks(): Link[] { + const links: Link[] = []; + + // Shape type declaration + links.push({ + source: this.nodeShapeUri, + predicate: "rdf://type", + target: "sh://NodeShape" + }); + + // Target class + if (this.targetClass) { + links.push({ + source: this.nodeShapeUri, + predicate: "sh://targetClass", + target: this.targetClass + }); + } + + // Constructor actions + if (this.constructor_actions && this.constructor_actions.length > 0) { + links.push({ + source: this.nodeShapeUri, + predicate: "ad4m://constructor", + target: `literal://string:${JSON.stringify(this.constructor_actions)}` + }); + } + + // Destructor actions + if (this.destructor_actions && this.destructor_actions.length > 0) { + links.push({ + source: this.nodeShapeUri, + predicate: "ad4m://destructor", + target: `literal://string:${JSON.stringify(this.destructor_actions)}` + }); + } + + // Property shapes (each gets a named URI: {namespace}/{ClassName}.{propertyName}) + for (let i = 0; i < this.properties.length; i++) { + const prop = this.properties[i]; + + // Generate named property shape URI + let propShapeId: string; + if (prop.name && this.targetClass) { + // Extract namespace from targetClass + const namespace = extractNamespace(this.targetClass); + const className = extractLocalName(this.targetClass); + // Use format: {namespace}{ClassName}.{propertyName} + propShapeId = `${namespace}${className}.${prop.name}`; + } else { + // Fallback to blank node if name is missing + propShapeId = `_:propShape${i}`; + } + + // Link shape to property shape + links.push({ + source: this.nodeShapeUri, + predicate: "sh://property", + target: propShapeId + }); + + // Property path + links.push({ + source: propShapeId, + predicate: "sh://path", + target: prop.path + }); + + // Constraints + if (prop.datatype) { + links.push({ + source: propShapeId, + predicate: "sh://datatype", + target: prop.datatype + }); + } + + if (prop.nodeKind) { + links.push({ + source: propShapeId, + predicate: "sh://nodeKind", + target: `sh://${prop.nodeKind}` + }); + } + + if (prop.minCount !== undefined) { + links.push({ + source: propShapeId, + predicate: "sh://minCount", + target: `literal://${prop.minCount}^^xsd:integer` + }); + } + + if (prop.maxCount !== undefined) { + links.push({ + source: propShapeId, + predicate: "sh://maxCount", + target: `literal://${prop.maxCount}^^xsd:integer` + }); + } + + if (prop.pattern) { + links.push({ + source: propShapeId, + predicate: "sh://pattern", + target: `literal://${prop.pattern}` + }); + } + + if (prop.minInclusive !== undefined) { + links.push({ + source: propShapeId, + predicate: "sh://minInclusive", + target: `literal://${prop.minInclusive}` + }); + } + + if (prop.maxInclusive !== undefined) { + links.push({ + source: propShapeId, + predicate: "sh://maxInclusive", + target: `literal://${prop.maxInclusive}` + }); + } + + if (prop.hasValue) { + links.push({ + source: propShapeId, + predicate: "sh://hasValue", + target: `literal://${prop.hasValue}` + }); + } + + // AD4M-specific metadata + if (prop.local !== undefined) { + links.push({ + source: propShapeId, + predicate: "ad4m://local", + target: `literal://${prop.local}` + }); + } + + if (prop.writable !== undefined) { + links.push({ + source: propShapeId, + predicate: "ad4m://writable", + target: `literal://${prop.writable}` + }); + } + + if (prop.resolveLanguage) { + links.push({ + source: propShapeId, + predicate: "ad4m://resolveLanguage", + target: `literal://string:${prop.resolveLanguage}` + }); + } + + // AD4M-specific actions + if (prop.setter && prop.setter.length > 0) { + links.push({ + source: propShapeId, + predicate: "ad4m://setter", + target: `literal://string:${JSON.stringify(prop.setter)}` + }); + } + + if (prop.adder && prop.adder.length > 0) { + links.push({ + source: propShapeId, + predicate: "ad4m://adder", + target: `literal://string:${JSON.stringify(prop.adder)}` + }); + } + + if (prop.remover && prop.remover.length > 0) { + links.push({ + source: propShapeId, + predicate: "ad4m://remover", + target: `literal://string:${JSON.stringify(prop.remover)}` + }); + } + } + + return links; + } + + /** + * Reconstruct shape from AD4M Links + */ + static fromLinks(links: Link[], shapeUri: string): SHACLShape { + // Find target class + const targetClassLink = links.find(l => + l.source === shapeUri && l.predicate === "sh://targetClass" + ); + + const shape = new SHACLShape(shapeUri, targetClassLink?.target); + + // Find constructor actions + const constructorLink = links.find(l => + l.source === shapeUri && l.predicate === "ad4m://constructor" + ); + if (constructorLink) { + try { + const jsonStr = constructorLink.target.replace('literal://string:', ''); + shape.constructor_actions = JSON.parse(jsonStr); + } catch (e) { + // Ignore parse errors + } + } + + // Find destructor actions + const destructorLink = links.find(l => + l.source === shapeUri && l.predicate === "ad4m://destructor" + ); + if (destructorLink) { + try { + const jsonStr = destructorLink.target.replace('literal://string:', ''); + shape.destructor_actions = JSON.parse(jsonStr); + } catch (e) { + // Ignore parse errors + } + } + + // Find all property shapes + const propShapeLinks = links.filter(l => + l.source === shapeUri && l.predicate === "sh://property" + ); + + for (const propLink of propShapeLinks) { + const propShapeId = propLink.target; + + // Reconstruct property from its links + const pathLink = links.find(l => + l.source === propShapeId && l.predicate === "sh://path" + ); + + if (!pathLink) continue; + + // Extract property name from propShapeId if it's a named URI + // Format: {namespace}{ClassName}.{propertyName} + let propertyName: string | undefined; + if (!propShapeId.startsWith('_:')) { + const lastDotIndex = propShapeId.lastIndexOf('.'); + if (lastDotIndex !== -1) { + propertyName = propShapeId.substring(lastDotIndex + 1); + } + } + + const prop: SHACLPropertyShape = { + name: propertyName, + path: pathLink.target + }; + + // Extract constraints + const datatypeLink = links.find(l => + l.source === propShapeId && l.predicate === "sh://datatype" + ); + if (datatypeLink) prop.datatype = datatypeLink.target; + + const nodeKindLink = links.find(l => + l.source === propShapeId && l.predicate === "sh://nodeKind" + ); + if (nodeKindLink) { + prop.nodeKind = nodeKindLink.target.replace('sh://', '') as any; + } + + const minCountLink = links.find(l => + l.source === propShapeId && l.predicate === "sh://minCount" + ); + if (minCountLink) { + // Handle both formats: literal://5^^xsd:integer and literal://number:5 + let val = minCountLink.target.replace('literal://', '').replace(/\^\^.*$/, ''); + if (val.startsWith('number:')) val = val.substring(7); + prop.minCount = parseInt(val); + } + + const maxCountLink = links.find(l => + l.source === propShapeId && l.predicate === "sh://maxCount" + ); + if (maxCountLink) { + // Handle both formats: literal://5^^xsd:integer and literal://number:5 + let val = maxCountLink.target.replace('literal://', '').replace(/\^\^.*$/, ''); + if (val.startsWith('number:')) val = val.substring(7); + prop.maxCount = parseInt(val); + } + + const patternLink = links.find(l => + l.source === propShapeId && l.predicate === "sh://pattern" + ); + if (patternLink) { + prop.pattern = patternLink.target.replace('literal://', ''); + } + + const minInclusiveLink = links.find(l => + l.source === propShapeId && l.predicate === "sh://minInclusive" + ); + if (minInclusiveLink) { + // Handle both formats: literal://5 and literal://number:5 + let val = minInclusiveLink.target.replace('literal://', ''); + if (val.startsWith('number:')) val = val.substring(7); + prop.minInclusive = parseFloat(val); + } + + const maxInclusiveLink = links.find(l => + l.source === propShapeId && l.predicate === "sh://maxInclusive" + ); + if (maxInclusiveLink) { + // Handle both formats: literal://5 and literal://number:5 + let val = maxInclusiveLink.target.replace('literal://', ''); + if (val.startsWith('number:')) val = val.substring(7); + prop.maxInclusive = parseFloat(val); + } + + const hasValueLink = links.find(l => + l.source === propShapeId && l.predicate === "sh://hasValue" + ); + if (hasValueLink) { + prop.hasValue = hasValueLink.target.replace('literal://', ''); + } + + // AD4M-specific + const localLink = links.find(l => + l.source === propShapeId && l.predicate === "ad4m://local" + ); + if (localLink) { + // Handle both formats: literal://true and literal://boolean:true + let val = localLink.target.replace('literal://', ''); + if (val.startsWith('boolean:')) val = val.substring(8); + prop.local = val === 'true'; + } + + const writableLink = links.find(l => + l.source === propShapeId && l.predicate === "ad4m://writable" + ); + if (writableLink) { + // Handle both formats: literal://true and literal://boolean:true + let val = writableLink.target.replace('literal://', ''); + if (val.startsWith('boolean:')) val = val.substring(8); + prop.writable = val === 'true'; + } + + const resolveLangLink = links.find(l => + l.source === propShapeId && l.predicate === "ad4m://resolveLanguage" + ); + if (resolveLangLink) { + prop.resolveLanguage = resolveLangLink.target.replace('literal://string:', ''); + } + + // Parse action arrays + const setterLink = links.find(l => + l.source === propShapeId && l.predicate === "ad4m://setter" + ); + if (setterLink) { + try { + const jsonStr = setterLink.target.replace('literal://string:', ''); + prop.setter = JSON.parse(jsonStr); + } catch (e) { + // Ignore parse errors + } + } + + const adderLink = links.find(l => + l.source === propShapeId && l.predicate === "ad4m://adder" + ); + if (adderLink) { + try { + const jsonStr = adderLink.target.replace('literal://string:', ''); + prop.adder = JSON.parse(jsonStr); + } catch (e) { + // Ignore parse errors + } + } + + const removerLink = links.find(l => + l.source === propShapeId && l.predicate === "ad4m://remover" + ); + if (removerLink) { + try { + const jsonStr = removerLink.target.replace('literal://string:', ''); + prop.remover = JSON.parse(jsonStr); + } catch (e) { + // Ignore parse errors + } + } + + shape.addProperty(prop); + } + + return shape; + } + + /** + * Convert the shape to a JSON-serializable object. + * Useful for passing to addSdna() as shaclJson parameter. + * + * @returns JSON-serializable object representing the shape + */ + toJSON(): object { + return { + node_shape_uri: this.nodeShapeUri, + target_class: this.targetClass, + properties: this.properties.map(p => ({ + path: p.path, + name: p.name, + datatype: p.datatype, + node_kind: p.nodeKind, + min_count: p.minCount, + max_count: p.maxCount, + min_inclusive: p.minInclusive, + max_inclusive: p.maxInclusive, + pattern: p.pattern, + has_value: p.hasValue, + local: p.local, + writable: p.writable, + resolve_language: p.resolveLanguage, + setter: p.setter, + adder: p.adder, + remover: p.remover, + })), + constructor_actions: this.constructor_actions, + destructor_actions: this.destructor_actions, + }; + } + + /** + * Create a shape from a JSON object (inverse of toJSON) + */ + static fromJSON(json: any): SHACLShape { + const shape = json.node_shape_uri + ? new SHACLShape(json.node_shape_uri, json.target_class) + : new SHACLShape(json.target_class); + + for (const p of json.properties || []) { + shape.addProperty({ + path: p.path, + name: p.name, + datatype: p.datatype, + nodeKind: p.node_kind, + minCount: p.min_count, + maxCount: p.max_count, + minInclusive: p.min_inclusive, + maxInclusive: p.max_inclusive, + pattern: p.pattern, + hasValue: p.has_value, + local: p.local, + writable: p.writable, + resolveLanguage: p.resolve_language, + setter: p.setter, + adder: p.adder, + remover: p.remover, + }); + } + + if (json.constructor_actions) { + shape.constructor_actions = json.constructor_actions; + } + if (json.destructor_actions) { + shape.destructor_actions = json.destructor_actions; + } + + return shape; + } +} diff --git a/deno.lock b/deno.lock index fe777714b..346a81b8d 100644 --- a/deno.lock +++ b/deno.lock @@ -749,7 +749,6 @@ "npm:@types/glob@8.1.0", "npm:@types/node-fetch@^2.6.1", "npm:@types/node@18", - "npm:@types/unzipper@~0.10.5", "npm:@types/uuid@^8.3.2", "npm:@types/ws@8.5.4", "npm:@types/yargs@^17.0.8", @@ -758,19 +757,16 @@ "npm:esm@^3.2.25", "npm:esmify@^2.1.1", "npm:express@4.18.2", - "npm:find-process@^1.4.7", "npm:fs-extra@^10.0.1", - "npm:get-port@5.1.1", + "npm:get-port@^5.1.1", "npm:glob@^7.2.0", "npm:graphql-ws@5.12.0", "npm:graphql@15.7.2", "npm:node-fetch@2", - "npm:node-wget-js@^1.0.1", "npm:subscriptions-transport-ws@0.11", "npm:tree-kill@^1.2.2", "npm:ts-node@^10.5.0", "npm:typescript@^4.6.2", - "npm:unzipper@~0.10.11", "npm:uuid@^8.3.2", "npm:wget-improved@^3.3.0", "npm:ws@8.13.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3372fe3c..b79d6f132 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19633,6 +19633,10 @@ snapshots: dependencies: acorn: 8.11.3 + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn-node@1.8.2: dependencies: acorn: 7.4.1 @@ -22813,8 +22817,8 @@ snapshots: espree@9.6.1: dependencies: - acorn: 8.11.3 - acorn-jsx: 5.3.2(acorn@8.11.3) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 3.4.3 esprima@1.2.2: {} diff --git a/rust-executor/Cargo.toml b/rust-executor/Cargo.toml index e56b4fcb5..4ad0c1b90 100644 --- a/rust-executor/Cargo.toml +++ b/rust-executor/Cargo.toml @@ -48,6 +48,7 @@ sys_traits = "=0.1.9" # deno_runtime = {version = "0.162.0", path = "../../deno/runtime"} tokio = { version = "1.25.0", features = ["full"] } url = "2.3.1" +urlencoding = "2.1" futures = "0.3.28" tokio-stream = { version = "0.1.12", features = ["sync"] } lazy_static = "1.4.0" diff --git a/rust-executor/src/db.rs b/rust-executor/src/db.rs index 7c6bd08d2..cf6795ca3 100644 --- a/rust-executor/src/db.rs +++ b/rust-executor/src/db.rs @@ -1439,7 +1439,8 @@ impl Ad4mDb { Ok((link_expression, status)) })?; let links: Result, _> = link_iter.collect(); - Ok(links?) + let result = links?; + Ok(result) } pub fn get_links_by_predicate( diff --git a/rust-executor/src/graphql/mutation_resolvers.rs b/rust-executor/src/graphql/mutation_resolvers.rs index 76ab28c87..52ab81674 100644 --- a/rust-executor/src/graphql/mutation_resolvers.rs +++ b/rust-executor/src/graphql/mutation_resolvers.rs @@ -1470,7 +1470,7 @@ impl Mutation { .links .into_iter() .map(Link::from) - .map(|l| create_signed_expression(l, &agent_context)) + .map(|l| create_signed_expression(l.normalize(), &agent_context)) .filter_map(Result::ok) .map(LinkExpression::from) .map(|l| DecoratedLinkExpression::from((l, LinkStatus::Shared))) @@ -1526,7 +1526,7 @@ impl Mutation { .links .into_iter() .map(Link::from) - .map(|l| create_signed_expression(l, &agent_context)) + .map(|l| create_signed_expression(l.normalize(), &agent_context)) .filter_map(Result::ok) .map(LinkExpression::from) .map(|l| DecoratedLinkExpression::from((l, LinkStatus::Shared))) @@ -1828,8 +1828,9 @@ impl Mutation { context: &RequestContext, uuid: String, name: String, - sdna_code: String, + sdna_code: Option, sdna_type: String, + shacl_json: Option, ) -> FieldResult { check_capability( &context.capabilities, @@ -1840,7 +1841,13 @@ impl Mutation { let sdna_type = SdnaType::from_string(&sdna_type) .map_err(|e| FieldError::new(e, graphql_value!({ "invalid_sdna_type": sdna_type })))?; perspective - .add_sdna(name, sdna_code, sdna_type, &agent_context) + .add_sdna( + name, + sdna_code.unwrap_or_default(), + sdna_type, + shacl_json, + &agent_context, + ) .await?; Ok(true) } diff --git a/rust-executor/src/graphql/query_resolvers.rs b/rust-executor/src/graphql/query_resolvers.rs index 72fd344e9..98d7e279a 100644 --- a/rust-executor/src/graphql/query_resolvers.rs +++ b/rust-executor/src/graphql/query_resolvers.rs @@ -506,6 +506,7 @@ impl Query { )) } + /// Get all subject class names from SHACL links (Prolog-free implementation) async fn perspective_query_surreal_db( &self, context: &RequestContext, diff --git a/rust-executor/src/perspectives/mod.rs b/rust-executor/src/perspectives/mod.rs index 5b1d1ee21..03ac2cb31 100644 --- a/rust-executor/src/perspectives/mod.rs +++ b/rust-executor/src/perspectives/mod.rs @@ -1,6 +1,8 @@ pub mod migration; pub mod perspective_instance; pub mod sdna; +pub mod shacl_parser; +pub mod shacl_to_prolog; pub mod utils; // TODO: Remove this module after all users have migrated to SurrealDB use crate::graphql::graphql_types::{ LinkQuery, LinkStatus, PerspectiveExpression, PerspectiveHandle, PerspectiveRemovedWithOwner, diff --git a/rust-executor/src/perspectives/perspective_instance.rs b/rust-executor/src/perspectives/perspective_instance.rs index 2a998e3bf..0ac58cc31 100644 --- a/rust-executor/src/perspectives/perspective_instance.rs +++ b/rust-executor/src/perspectives/perspective_instance.rs @@ -1,4 +1,5 @@ use super::sdna::{generic_link_fact, is_sdna_link}; +use super::shacl_parser::parse_shacl_to_links; use super::update_perspective; use super::utils::{ prolog_get_all_string_bindings, prolog_get_first_string_binding, prolog_resolution_to_string, @@ -34,7 +35,6 @@ use chrono::DateTime; use deno_core::anyhow::anyhow; use deno_core::error::AnyError; use futures::future; -use json5; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, HashMap}; @@ -44,6 +44,7 @@ use std::time::Duration; use tokio::sync::{Mutex, RwLock}; use tokio::time::{sleep, Instant}; use tokio::{join, time}; +use urlencoding; use uuid; use uuid::Uuid; @@ -871,7 +872,7 @@ impl PerspectiveInstance { context: &AgentContext, ) -> Result { link.validate()?; - let link_expr: LinkExpression = create_signed_expression(link, context)?.into(); + let link_expr: LinkExpression = create_signed_expression(link.normalize(), context)?.into(); self.add_link_expression(link_expr, status, batch_id).await } @@ -1086,7 +1087,7 @@ impl PerspectiveInstance { } let link_expressions: Result, _> = links .into_iter() - .map(|l| create_signed_expression(l, context).map(LinkExpression::from)) + .map(|l| create_signed_expression(l.normalize(), context).map(LinkExpression::from)) .collect(); let link_expressions = link_expressions?; @@ -1142,7 +1143,7 @@ impl PerspectiveInstance { } let additions = addition_links .into_iter() - .map(|l| create_signed_expression(l, context)) + .map(|l| create_signed_expression(l.normalize(), context)) .map(|r| r.map(LinkExpression::from)) .collect::, AnyError>>()?; let removals = mutations @@ -1220,7 +1221,7 @@ impl PerspectiveInstance { }; let new_link_expression = - LinkExpression::from(create_signed_expression(new_link, context)?); + LinkExpression::from(create_signed_expression(new_link.normalize(), context)?); if let Some(batch_id) = batch_id { let mut batches = self.batch_store.write().await; @@ -1425,6 +1426,69 @@ impl PerspectiveInstance { Ok(all_sdna_links) } + /// Get all subject class names from SHACL links (Prolog-free implementation) + /// + /// This queries links with: + /// - predicate = "rdf://type" + /// - target = "ad4m://SubjectClass" + /// + /// The source of these links is the class URI (e.g., "recipe://Recipe") + /// We extract the class name from the URI. + pub async fn get_subject_classes_from_shacl(&self) -> Result, AnyError> { + let uuid = self.persisted.lock().await.uuid.clone(); + log::debug!( + "🔶 get_subject_classes_from_shacl: uuid={}, Querying for SHACL class links", + uuid + ); + // Query for SHACL class definition links + let shacl_class_links = self + .get_links_local(&LinkQuery { + predicate: Some("rdf://type".to_string()), + target: Some("ad4m://SubjectClass".to_string()), + ..Default::default() + }) + .await?; + log::debug!( + "🔶 get_subject_classes_from_shacl: Found {} links", + shacl_class_links.len() + ); + for (link, _status) in &shacl_class_links { + log::debug!( + "🔶 get_subject_classes_from_shacl: Link: {} -> {:?} -> {}", + link.data.source, + link.data.predicate, + link.data.target + ); + } + + // Extract class names from source URIs + let mut class_names: Vec = shacl_class_links + .iter() + .filter_map(|(link, _status)| { + let source = &link.data.source; + // Class URI format: "namespace://ClassName" (e.g., "recipe://Recipe") + // We want to extract "ClassName" + if let Some(idx) = source.rfind("://") { + let after_scheme = &source[idx + 3..]; + // Handle paths like "namespace://path/ClassName" + if let Some(last_slash) = after_scheme.rfind('/') { + Some(after_scheme[last_slash + 1..].to_string()) + } else { + Some(after_scheme.to_string()) + } + } else { + None + } + }) + .collect(); + + // Remove duplicates + class_names.sort(); + class_names.dedup(); + + Ok(class_names) + } + async fn get_links_local( &self, query: &LinkQuery, @@ -1462,14 +1526,29 @@ impl PerspectiveInstance { if let Some(predicate) = &query.predicate { result.retain(|(link, _status)| link.data.predicate.as_ref() == Some(predicate)); + log::debug!( + "get_links_local: after predicate filter ({}): {} links", + predicate, + result.len() + ); } if let Some(target) = &query.target { result.retain(|(link, _status)| link.data.target == *target); + log::debug!( + "get_links_local: after target filter ({}): {} links", + target, + result.len() + ); } if let Some(source) = &query.source { result.retain(|(link, _status)| link.data.source == *source); + log::debug!( + "get_links_local: after source filter ({}): {} links", + source, + result.len() + ); } let until_date: Option> = @@ -1553,11 +1632,13 @@ impl PerspectiveInstance { } /// Adds the given Social DNA code to the perspective's SDNA code + /// If shacl_json is provided, also stores SHACL as queryable RDF links pub async fn add_sdna( &mut self, name: String, mut sdna_code: String, sdna_type: SdnaType, + shacl_json: Option, context: &AgentContext, ) -> Result { //let mut added = false; @@ -1570,12 +1651,49 @@ impl PerspectiveInstance { SdnaType::Custom => "ad4m://has_custom_sdna", }; - let literal_name = Literal::from_string(name) + let literal_name = Literal::from_string(name.clone()) .to_url() .expect("just initialized Literal couldn't be turned into URL"); let mut sdna_links: Vec = Vec::new(); + // Preserve original Prolog code for SHACL generation if needed + let original_prolog_code = sdna_code.clone(); + let perspective_uuid = self.persisted.lock().await.uuid.clone(); + + // Check if SHACL definition already exists for this class BEFORE doing anything + if matches!(sdna_type, SdnaType::SubjectClass) { + // Check for any existing SubjectClass with this name, regardless of namespace + // We query by target (ad4m://SubjectClass) and then filter by class name + let all_class_links = self + .get_links_local(&LinkQuery { + predicate: Some("rdf://type".to_string()), + target: Some("ad4m://SubjectClass".to_string()), + ..Default::default() + }) + .await?; + + // Check if any existing class matches this name + let exists = all_class_links.iter().any(|(link, _)| { + // Extract class name from source URI (e.g., "flux://Channel" -> "Channel") + link.data + .source + .split("://") + .last() + .and_then(|s| s.split('/').last()) + .map(|class_name| class_name == name) + .unwrap_or(false) + }); + + if exists { + log::info!( + "Class '{}' SHACL definition already exists, skipping duplicate", + name + ); + return Ok(true); + } + } + if (Literal::from_url(sdna_code.clone())).is_err() { sdna_code = Literal::from_string(sdna_code) .to_url() @@ -1604,14 +1722,25 @@ impl PerspectiveInstance { target: literal_name.clone(), }); + // Store the Prolog code for backward compatibility with getSdna() + // SHACL links are the source of truth for schema operations, + // but Prolog code is still stored for retrieval sdna_links.push(Link { source: literal_name.clone(), predicate: Some("ad4m://sdna".to_string()), - target: sdna_code, + target: sdna_code.clone(), }); self.add_links(sdna_links, LinkStatus::Shared, None, context) .await?; + + // Handle SHACL links if SHACL JSON provided explicitly + if let Some(shacl) = shacl_json { + let shacl_links = parse_shacl_to_links(&shacl, &name)?; + self.add_links(shacl_links, LinkStatus::Shared, None, context) + .await?; + } + //added = true; //} // Mutex guard is automatically dropped here @@ -1999,11 +2128,8 @@ impl PerspectiveInstance { .await } PrologMode::Disabled => { - log::warn!( - "⚠️ Prolog query received but Prolog is DISABLED (query: {})", - query - ); - Err(anyhow!("Prolog is disabled")) + // Return empty matches instead of False/Error to allow SHACL-based SDNA to work + Ok(QueryResolution::Matches(vec![])) } } } @@ -2032,10 +2158,11 @@ impl PerspectiveInstance { } PrologMode::Disabled => { log::warn!( - "⚠️ Prolog subscription query received but Prolog is DISABLED (query: {})", + "⚠️ Prolog subscription query received but Prolog is DISABLED (query: {}), returning empty result", query ); - Err(anyhow!("Prolog is disabled")) + // Return empty result instead of error to allow SHACL-based SDNA to work + Ok(QueryResolution::False) } } } @@ -2070,10 +2197,11 @@ impl PerspectiveInstance { } PrologMode::Disabled => { log::warn!( - "⚠️ Prolog subscription query received but Prolog is DISABLED (query: {})", + "⚠️ Prolog subscription query received but Prolog is DISABLED (query: {}), returning empty result", query ); - Err(anyhow!("Prolog is disabled")) + // Return empty result instead of error to allow SHACL-based SDNA to work + Ok(QueryResolution::False) } } } @@ -2164,7 +2292,7 @@ impl PerspectiveInstance { ) .await } - PrologMode::Disabled => Err(anyhow!("Prolog is disabled")), + PrologMode::Disabled => Ok(QueryResolution::Matches(vec![])), } } @@ -2281,7 +2409,7 @@ impl PerspectiveInstance { ) .await } - PrologMode::Disabled => Err(anyhow!("Prolog is disabled")), + PrologMode::Disabled => Ok(QueryResolution::Matches(vec![])), } } @@ -2511,14 +2639,12 @@ impl PerspectiveInstance { let self_clone = self.clone(); tokio::spawn(async move { - // In Simple or SdnaOnly mode, just trigger subscription checks + // In Disabled, Simple, or SdnaOnly mode, just trigger subscription checks // (Pooled mode prolog updates don't apply - run_query_all only works in Pooled mode) - if PROLOG_MODE == PrologMode::Simple || PROLOG_MODE == PrologMode::SdnaOnly { - log::debug!( - "Prolog facts update ({:?} mode): triggering subscription checks", - PROLOG_MODE - ); - + if PROLOG_MODE == PrologMode::Disabled + || PROLOG_MODE == PrologMode::Simple + || PROLOG_MODE == PrologMode::SdnaOnly + { // Trigger notification, prolog subscription, and surreal subscription checks *(self_clone.trigger_notification_check.lock().await) = true; *(self_clone.trigger_prolog_subscription_check.lock().await) = true; @@ -3302,95 +3428,148 @@ impl PerspectiveInstance { //let method_start = std::time::Instant::now(); //log::info!("🔍 SUBJECT CLASS: Starting class name resolution..."); - Ok(if subject_class.class_name.is_some() { - //log::info!("🔍 SUBJECT CLASS: Using provided class name '{}' in {:?}", class_name, method_start.elapsed()); - subject_class.class_name.unwrap() + Ok(if let Some(class_name) = subject_class.class_name { + class_name } else { - let query = subject_class.query.ok_or(anyhow!( - "SubjectClassOption needs to either have `name` or `query` set" - ))?; + return Err(anyhow!( + "SubjectClassOption requires `className` to be set. Query-based lookup has been removed; resolve the class name client-side." + )); + }) + } - //log::info!("🔍 SUBJECT CLASS: Running prolog query to resolve class name: {}", query); - //let query_start = std::time::Instant::now(); + /// Parse actions JSON from a literal target (format: "literal://string:{json}") + fn parse_actions_from_literal(target: &str) -> Result, AnyError> { + let prefix = "literal://string:"; + if !target.starts_with(prefix) { + return Err(anyhow!("Invalid literal format: {}", target)); + } + let json_str = &target[prefix.len()..]; + // Decode URL-encoded characters if present + let decoded = urlencoding::decode(json_str) + .map(|s| s.to_string()) + .unwrap_or_else(|_| json_str.to_string()); + serde_json::from_str(&decoded) + .map_err(|e| anyhow!("Failed to parse actions JSON: {} - input: {}", e, decoded)) + } - let result = self - .prolog_query_sdna_with_context(query.to_string(), context) - .await - .map_err(|e| { - log::error!("Error creating subject: {:?}", e); - e - })?; + /// Get actions from SHACL links for a shape-level predicate (constructor/destructor) + async fn get_shape_actions_from_shacl( + &self, + class_name: &str, + predicate: &str, + ) -> Result>, AnyError> { + // Query SurrealDB for links with the given predicate whose source ends with {ClassName}Shape + let shape_suffix = format!("{}Shape", class_name); + let uuid = self.persisted.lock().await.uuid.clone(); - //log::info!("🔍 SUBJECT CLASS: Prolog query completed in {:?}", query_start.elapsed()); + let links = self + .surreal_service + .get_links_by_predicate_and_source_suffix(&uuid, predicate, &shape_suffix) + .await?; - prolog_get_first_string_binding(&result, "Class") - .ok_or(anyhow!("No matching subject class found!"))? - }) + // Return the first match + if let Some(link) = links.first() { + return Self::parse_actions_from_literal(&link.data.target).map(Some); + } + + Ok(None) } - async fn get_actions_from_prolog( + /// Get actions from SHACL links for a property-level predicate (setter/adder/remover) + async fn get_property_actions_from_shacl( &self, - query: String, - context: &AgentContext, + class_name: &str, + property: &str, + predicate: &str, ) -> Result>, AnyError> { - let result = self.prolog_query_sdna_with_context(query, context).await?; - - if let Some(actions_str) = prolog_get_first_string_binding(&result, "Actions") { - // json5 seems to have a bug, blocking when a property is set to undefined - let sanitized_str = actions_str.replace("undefined", "null"); - json5::from_str(&sanitized_str) - .map(Some) - .map_err(|e| anyhow!("Failed to parse actions: {}", e)) - } else { - Ok(None) + // Property shape URI format: {namespace}{ClassName}.{propertyName} + let prop_suffix = format!("{}.{}", class_name, property); + let uuid = self.persisted.lock().await.uuid.clone(); + + let links = self + .surreal_service + .get_links_by_predicate_and_source_suffix(&uuid, predicate, &prop_suffix) + .await?; + + // Return the first match + if let Some(link) = links.first() { + return Self::parse_actions_from_literal(&link.data.target).map(Some); } + + Ok(None) } - async fn get_constructor_actions( + /// Get resolve language from SHACL links + async fn get_resolve_language_from_shacl( &self, class_name: &str, - context: &AgentContext, - ) -> Result, AnyError> { - //let method_start = std::time::Instant::now(); - //log::info!("🏗️ CONSTRUCTOR: Getting constructor actions for class '{}'", class_name); + property: &str, + ) -> Result, AnyError> { + let prop_suffix = format!("{}.{}", class_name, property); + let uuid = self.persisted.lock().await.uuid.clone(); - let query = format!( - r#"subject_class("{}", C), constructor(C, Actions)"#, - class_name - ); + let links = self + .surreal_service + .get_links_by_predicate_and_source_suffix(&uuid, "ad4m://resolveLanguage", &prop_suffix) + .await?; + + if let Some(link) = links.first() { + // Extract value from literal://string:{value} + let prefix = "literal://string:"; + if link.data.target.starts_with(prefix) { + let encoded_value = &link.data.target[prefix.len()..]; + let decoded = urlencoding::decode(encoded_value) + .map_err(|e| anyhow!("Failed to decode resolve language value: {}", e))?; + return Ok(Some(decoded.to_string())); + } + } - //log::info!("🏗️ CONSTRUCTOR: Running prolog query: {}", query); - //let query_start = std::time::Instant::now(); + Ok(None) + } - //log::info!("🏗️ CONSTRUCTOR: Prolog query completed in {:?} (total: {:?})", - // query_start.elapsed(), method_start.elapsed()); + async fn get_constructor_actions(&self, class_name: &str) -> Result, AnyError> { + self.get_shape_actions_from_shacl(class_name, "ad4m://constructor") + .await? + .ok_or(anyhow!( + "No SHACL constructor found for class: {}. Ensure the class has SHACL definitions.", + class_name + )) + } - self.get_actions_from_prolog(query, context) + async fn get_destructor_actions(&self, class_name: &str) -> Result, AnyError> { + self.get_shape_actions_from_shacl(class_name, "ad4m://destructor") .await? - .ok_or(anyhow!("No constructor found for class: {}", class_name)) + .ok_or(anyhow!( + "No SHACL destructor found for class: {}. Ensure the class has SHACL definitions.", + class_name + )) } async fn get_property_setter_actions( &self, class_name: &str, property: &str, - context: &AgentContext, ) -> Result>, AnyError> { - //let method_start = std::time::Instant::now(); - //log::info!("🔧 PROPERTY SETTER: Getting setter for class '{}', property '{}'", class_name, property); - - let query = format!( - r#"subject_class("{}", C), property_setter(C, "{}", Actions)"#, - class_name, property - ); - - //log::info!("🔧 PROPERTY SETTER: Running prolog query: {}", query); - //let query_start = std::time::Instant::now(); + self.get_property_actions_from_shacl(class_name, property, "ad4m://setter") + .await + } - //log::info!("🔧 PROPERTY SETTER: Prolog query completed in {:?} (total: {:?})", - // query_start.elapsed(), method_start.elapsed()); + async fn get_collection_adder_actions( + &self, + class_name: &str, + collection: &str, + ) -> Result>, AnyError> { + self.get_property_actions_from_shacl(class_name, collection, "ad4m://adder") + .await + } - self.get_actions_from_prolog(query, context).await + async fn get_collection_remover_actions( + &self, + class_name: &str, + collection: &str, + ) -> Result>, AnyError> { + self.get_property_actions_from_shacl(class_name, collection, "ad4m://remover") + .await } async fn resolve_property_value( @@ -3398,15 +3577,13 @@ impl PerspectiveInstance { class_name: &str, property: &str, value: &serde_json::Value, - context: &AgentContext, ) -> Result { - let resolve_result = self.prolog_query_with_context(format!( - r#"subject_class("{}", C), property_resolve(C, "{}"), property_resolve_language(C, "{}", Language)"#, - class_name, property, property - ), context).await?; + // Get resolve language from SHACL links + let resolve_language = self + .get_resolve_language_from_shacl(class_name, property) + .await?; - if let Some(resolve_language) = prolog_get_first_string_binding(&resolve_result, "Language") - { + if let Some(resolve_language) = resolve_language { // Create an expression for the value let mut lock = crate::js_core::JS_CORE_HANDLE.lock().await; let content = serde_json::to_string(value) @@ -3475,7 +3652,7 @@ impl PerspectiveInstance { //log::info!("🎯 CREATE SUBJECT: Got class name '{}' in {:?}", class_name, class_name_start.elapsed()); //let constructor_start = std::time::Instant::now(); - let mut commands = self.get_constructor_actions(&class_name, context).await?; + let mut commands = self.get_constructor_actions(&class_name).await?; //log::info!("🎯 CREATE SUBJECT: Got {} constructor actions in {:?}", // commands.len(), constructor_start.elapsed()); @@ -3486,12 +3663,11 @@ impl PerspectiveInstance { if let serde_json::Value::Object(obj) = obj { for (prop, value) in obj.iter() { //let prop_start = std::time::Instant::now(); - if let Some(setter_commands) = self - .get_property_setter_actions(&class_name, prop, context) - .await? + if let Some(setter_commands) = + self.get_property_setter_actions(&class_name, prop).await? { let target_value = self - .resolve_property_value(&class_name, prop, value, context) + .resolve_property_value(&class_name, prop, value) .await?; //log::info!("🎯 CREATE SUBJECT: Property '{}' setter resolved in {:?}", @@ -4315,7 +4491,7 @@ impl PerspectiveInstance { // Process additions for link in diff.additions { let status = link.status.unwrap_or(LinkStatus::Shared); - let signed_expr = create_signed_expression(link.data, context)?; + let signed_expr = create_signed_expression(link.data.normalize(), context)?; let decorated = DecoratedLinkExpression::from((LinkExpression::from(signed_expr), status.clone())); @@ -5309,48 +5485,59 @@ mod tests { println!("\n=== Step 1: Adding Recipe SDNA ==="); - // Step 1: Add Recipe SDNA with two required properties (matching subject.pl format exactly) - let recipe_sdna = r#" -subject_class("Recipe", c). -constructor(c, '[{action: "addLink", source: "this", predicate: "recipe://name", target: ""}, {action: "addLink", source: "this", predicate: "recipe://rating", target: "0"}]'). -instance(c, Base) :- - triple(Base, "recipe://name", _), - triple(Base, "recipe://rating", _). -property(c, "name"). -property_getter(c, Base, "name", Value) :- - triple(Base, "recipe://name", Value). -property_setter(c, "name", '[{action: "setSingleTarget", source: "this", predicate: "recipe://name", target: "value"}]'). -property(c, "rating"). -property_getter(c, Base, "rating", Value) :- - triple(Base, "recipe://rating", Value). -property_setter(c, "rating", '[{action: "setSingleTarget", source: "this", predicate: "recipe://rating", target: "value"}]'). -"#; + // Step 1: Add Recipe SDNA with SHACL JSON + let shacl_json = r#"{ + "target_class": "recipe://Recipe", + "constructor_actions": [ + {"action": "addLink", "source": "this", "predicate": "recipe://name", "target": ""}, + {"action": "addLink", "source": "this", "predicate": "recipe://rating", "target": "0"} + ], + "properties": [ + { + "path": "recipe://name", + "name": "name", + "datatype": "xsd://string", + "writable": true, + "setter": [{"action": "setSingleTarget", "source": "this", "predicate": "recipe://name", "target": "value"}] + }, + { + "path": "recipe://rating", + "name": "rating", + "datatype": "xsd://string", + "writable": true, + "setter": [{"action": "setSingleTarget", "source": "this", "predicate": "recipe://rating", "target": "value"}] + } + ] + }"#; perspective.ensure_prolog_engine_pool().await.unwrap(); perspective .add_sdna( "Recipe".to_string(), - recipe_sdna.to_string(), + "".to_string(), // Empty Prolog code SdnaType::SubjectClass, + Some(shacl_json.to_string()), // SHACL JSON &AgentContext::main_agent(), ) .await .unwrap(); - perspective.get_links(&LinkQuery::default()).await.unwrap(); + // Verify SHACL links were added let links = perspective.get_links(&LinkQuery::default()).await.unwrap(); - assert_eq!(links.len(), 2, "Expected 2 links"); + println!("SHACL links added: {} links total", links.len()); - let check = perspective - .prolog_query_with_context( - "subject_class(Name, _)".to_string(), - &AgentContext::main_agent(), - ) - .await - .unwrap(); - println!("Check: {:?}", check); + // Verify we have the subject class link + let class_link_exists = links.iter().any(|l| { + l.data.source == "ad4m://self" + && l.data.predicate == Some("ad4m://has_subject_class".to_string()) + && l.data.target == "literal://string:Recipe" + }); + assert!( + class_link_exists, + "Expected ad4m://has_subject_class link for Recipe" + ); - println!("✓ Recipe SDNA added, Prolog engine updated"); + println!("✓ Recipe SDNA added with SHACL definitions"); perspective .create_subject( diff --git a/rust-executor/src/perspectives/sdna.rs b/rust-executor/src/perspectives/sdna.rs index ad00383bd..a45896eeb 100644 --- a/rust-executor/src/perspectives/sdna.rs +++ b/rust-executor/src/perspectives/sdna.rs @@ -1,4 +1,5 @@ use crate::agent; +use crate::perspectives::shacl_to_prolog::generate_prolog_facts_from_shacl; use crate::types::{DecoratedLinkExpression, ExpressionRef, LanguageRef, Link}; use ad4m_client::literal::Literal; use chrono::DateTime; @@ -745,9 +746,15 @@ pub fn get_sdna_facts( } } + // Generate Prolog facts from SHACL links for backward compatibility + // This allows infer() queries and template matching to work with SHACL-only classes + lines.extend(generate_prolog_facts_from_shacl( + all_links, + &seen_subject_classes, + )); + Ok(lines) } - pub async fn init_engine_facts( all_links: Vec, neighbourhood_author: Option, diff --git a/rust-executor/src/perspectives/shacl_parser.rs b/rust-executor/src/perspectives/shacl_parser.rs new file mode 100644 index 000000000..bfd4296bb --- /dev/null +++ b/rust-executor/src/perspectives/shacl_parser.rs @@ -0,0 +1,813 @@ +use crate::types::Link; +use deno_core::error::AnyError; +use serde::{Deserialize, Serialize}; + +/// AD4M Action - represents a link operation (e.g., addLink, removeLink, setSingleTarget) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AD4MAction { + pub action: String, + pub source: String, + pub predicate: String, + pub target: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub local: Option, +} + +/// SHACL Shape structure (from TypeScript) +#[derive(Debug, Deserialize, Serialize)] +pub struct SHACLShape { + pub target_class: String, + pub properties: Vec, + /// Constructor actions for creating instances + #[serde(default)] + pub constructor_actions: Vec, + /// Destructor actions for removing instances + #[serde(default)] + pub destructor_actions: Vec, +} + +/// SHACL Property Shape structure +#[derive(Debug, Deserialize, Serialize)] +pub struct PropertyShape { + pub path: String, + pub name: Option, + pub datatype: Option, + pub min_count: Option, + pub max_count: Option, + pub writable: Option, + pub local: Option, + pub resolve_language: Option, + pub node_kind: Option, + pub collection: Option, + /// Setter action for single-valued properties + #[serde(default)] + pub setter: Vec, + /// Adder action for collection properties + #[serde(default)] + pub adder: Vec, + /// Remover action for collection properties + #[serde(default)] + pub remover: Vec, +} + +// ============================================================================ +// SHACL Flow structures (state machines without Prolog) +// ============================================================================ + +/// Link pattern for state detection +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LinkPattern { + /// Optional source pattern (if omitted, uses the expression address) + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + /// Required predicate to match + pub predicate: String, + /// Required target value to match + pub target: String, +} + +/// Flow State definition +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FlowState { + /// State name (e.g., "ready", "doing", "done") + pub name: String, + /// Numeric state value for ordering (e.g., 0, 0.5, 1) + pub value: f64, + /// Link pattern that indicates this state + pub state_check: LinkPattern, +} + +/// Flow Transition definition +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FlowTransition { + /// Name of this action (shown to users, e.g., "Start", "Finish") + pub action_name: String, + /// State to transition from + pub from_state: String, + /// State to transition to + pub to_state: String, + /// Actions to execute for this transition + pub actions: Vec, +} + +/// SHACL Flow structure - state machine definition +#[derive(Debug, Deserialize, Serialize)] +pub struct SHACLFlow { + /// Flow name (e.g., "TODO") + pub name: String, + /// Namespace for URIs (e.g., "todo://") + pub namespace: String, + /// Flowable condition - "any" or a LinkPattern + #[serde(default = "default_flowable")] + pub flowable: serde_json::Value, + /// Actions to execute when starting the flow + #[serde(default)] + pub start_action: Vec, + /// States in this flow + #[serde(default)] + pub states: Vec, + /// Transitions between states + #[serde(default)] + pub transitions: Vec, +} + +fn default_flowable() -> serde_json::Value { + serde_json::Value::String("any".to_string()) +} + +/// Parse Flow JSON to RDF links +pub fn parse_flow_to_links(flow_json: &str, flow_name: &str) -> Result, AnyError> { + let flow: SHACLFlow = serde_json::from_str(flow_json) + .map_err(|e| anyhow::anyhow!("Failed to parse Flow JSON: {}", e))?; + + let mut links = Vec::new(); + + let flow_uri = format!("{}{}Flow", flow.namespace, flow_name); + + // Flow type + links.push(Link { + source: flow_uri.clone(), + predicate: Some("rdf://type".to_string()), + target: "ad4m://Flow".to_string(), + }); + + // Flow name + links.push(Link { + source: flow_uri.clone(), + predicate: Some("ad4m://flowName".to_string()), + target: format!("literal://string:{}", urlencoding::encode(flow_name)), + }); + + // Flowable condition + let flowable_target = if flow.flowable == serde_json::Value::String("any".to_string()) { + "ad4m://any".to_string() + } else { + format!( + "literal://string:{}", + urlencoding::encode(&flow.flowable.to_string()) + ) + }; + links.push(Link { + source: flow_uri.clone(), + predicate: Some("ad4m://flowable".to_string()), + target: flowable_target, + }); + + // Start action + if !flow.start_action.is_empty() { + let actions_json = serde_json::to_string(&flow.start_action) + .map_err(|e| anyhow::anyhow!("Failed to serialize start actions: {}", e))?; + links.push(Link { + source: flow_uri.clone(), + predicate: Some("ad4m://startAction".to_string()), + target: format!("literal://string:{}", urlencoding::encode(&actions_json)), + }); + } + + // States + for state in &flow.states { + let state_uri = format!("{}{}.{}", flow.namespace, flow_name, state.name); + + // Link flow to state + links.push(Link { + source: flow_uri.clone(), + predicate: Some("ad4m://hasState".to_string()), + target: state_uri.clone(), + }); + + // State type + links.push(Link { + source: state_uri.clone(), + predicate: Some("rdf://type".to_string()), + target: "ad4m://FlowState".to_string(), + }); + + // State name + links.push(Link { + source: state_uri.clone(), + predicate: Some("ad4m://stateName".to_string()), + target: format!("literal://string:{}", urlencoding::encode(&state.name)), + }); + + // State value + links.push(Link { + source: state_uri.clone(), + predicate: Some("ad4m://stateValue".to_string()), + target: format!("literal://number:{}", state.value), + }); + + // State check pattern + let check_json = serde_json::to_string(&state.state_check) + .map_err(|e| anyhow::anyhow!("Failed to serialize state check: {}", e))?; + links.push(Link { + source: state_uri.clone(), + predicate: Some("ad4m://stateCheck".to_string()), + target: format!("literal://string:{}", urlencoding::encode(&check_json)), + }); + } + + // Transitions + for transition in &flow.transitions { + let transition_uri = format!( + "{}{}.{}To{}", + flow.namespace, flow_name, transition.from_state, transition.to_state + ); + let from_state_uri = format!("{}{}.{}", flow.namespace, flow_name, transition.from_state); + let to_state_uri = format!("{}{}.{}", flow.namespace, flow_name, transition.to_state); + + // Link flow to transition + links.push(Link { + source: flow_uri.clone(), + predicate: Some("ad4m://hasTransition".to_string()), + target: transition_uri.clone(), + }); + + // Transition type + links.push(Link { + source: transition_uri.clone(), + predicate: Some("rdf://type".to_string()), + target: "ad4m://FlowTransition".to_string(), + }); + + // Action name + links.push(Link { + source: transition_uri.clone(), + predicate: Some("ad4m://actionName".to_string()), + target: format!( + "literal://string:{}", + urlencoding::encode(&transition.action_name) + ), + }); + + // From state + links.push(Link { + source: transition_uri.clone(), + predicate: Some("ad4m://fromState".to_string()), + target: from_state_uri, + }); + + // To state + links.push(Link { + source: transition_uri.clone(), + predicate: Some("ad4m://toState".to_string()), + target: to_state_uri, + }); + + // Transition actions + if !transition.actions.is_empty() { + let actions_json = serde_json::to_string(&transition.actions) + .map_err(|e| anyhow::anyhow!("Failed to serialize transition actions: {}", e))?; + links.push(Link { + source: transition_uri.clone(), + predicate: Some("ad4m://transitionActions".to_string()), + target: format!("literal://string:{}", urlencoding::encode(&actions_json)), + }); + } + } + + Ok(links) +} + +/// Parse SHACL JSON to RDF links (Option 3: Named Property Shapes) +pub fn parse_shacl_to_links(shacl_json: &str, class_name: &str) -> Result, AnyError> { + let shape: SHACLShape = serde_json::from_str(shacl_json) + .map_err(|e| anyhow::anyhow!("Failed to parse SHACL JSON: {}", e))?; + + let mut links = Vec::new(); + + // Extract namespace from target_class (e.g., "recipe://Recipe" -> "recipe://") + let namespace = extract_namespace(&shape.target_class)?; + let shape_uri = format!("{}{}Shape", namespace, class_name); + + // Create name mapping for class lookup (needed by isSubjectInstance) + let name_mapping = format!("literal://string:shacl://{}", class_name); + + links.push(Link { + source: "ad4m://self".to_string(), + predicate: Some("ad4m://has_shacl".to_string()), + target: name_mapping.clone(), + }); + + links.push(Link { + source: name_mapping, + predicate: Some("ad4m://shacl_shape_uri".to_string()), + target: shape_uri.clone(), + }); + + // Class definition links + // Note: The ad4m://has_subject_class link is created by add_sdna(), not here, + // to avoid duplication since add_sdna() always creates that link + + links.push(Link { + source: shape.target_class.clone(), + predicate: Some("rdf://type".to_string()), + target: "ad4m://SubjectClass".to_string(), + }); + + links.push(Link { + source: shape.target_class.clone(), + predicate: Some("ad4m://shape".to_string()), + target: shape_uri.clone(), + }); + + links.push(Link { + source: shape_uri.clone(), + predicate: Some("rdf://type".to_string()), + target: "sh://NodeShape".to_string(), + }); + + links.push(Link { + source: shape_uri.clone(), + predicate: Some("sh://targetClass".to_string()), + target: shape.target_class.clone(), + }); + + // Constructor actions (stored as JSON in literal) + if !shape.constructor_actions.is_empty() { + let constructor_json = + serde_json::to_string(&shape.constructor_actions).unwrap_or_else(|_| "[]".to_string()); + links.push(Link { + source: shape_uri.clone(), + predicate: Some("ad4m://constructor".to_string()), + target: format!("literal://string:{}", constructor_json), + }); + } + + // Destructor actions (stored as JSON in literal) + if !shape.destructor_actions.is_empty() { + let destructor_json = + serde_json::to_string(&shape.destructor_actions).unwrap_or_else(|_| "[]".to_string()); + links.push(Link { + source: shape_uri.clone(), + predicate: Some("ad4m://destructor".to_string()), + target: format!("literal://string:{}", destructor_json), + }); + } + + // Property shape links (Option 3: Named Property Shapes) + for prop in shape.properties.iter() { + // Use name field if provided, otherwise extract from path + let prop_name = prop + .name + .as_ref() + .map(|n| n.clone()) + .unwrap_or_else(|| extract_local_name(&prop.path)); + + let prop_shape_uri = format!("{}{}.{}", namespace, class_name, prop_name); + + links.push(Link { + source: shape_uri.clone(), + predicate: Some("sh://property".to_string()), + target: prop_shape_uri.clone(), + }); + + // Determine type based on collection flag + let shape_type = if prop.collection.unwrap_or(false) { + "ad4m://CollectionShape" + } else { + "sh://PropertyShape" + }; + + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("rdf://type".to_string()), + target: shape_type.to_string(), + }); + + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("sh://path".to_string()), + target: prop.path.clone(), + }); + + // Optional constraints + if let Some(datatype) = &prop.datatype { + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("sh://datatype".to_string()), + target: datatype.clone(), + }); + } + + if let Some(min_count) = prop.min_count { + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("sh://minCount".to_string()), + target: format!("literal://{}^^xsd:integer", min_count), + }); + } + + if let Some(max_count) = prop.max_count { + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("sh://maxCount".to_string()), + target: format!("literal://{}^^xsd:integer", max_count), + }); + } + + if let Some(writable) = prop.writable { + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("ad4m://writable".to_string()), + target: format!("literal://{}", writable), + }); + } + + if let Some(resolve_lang) = &prop.resolve_language { + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("ad4m://resolveLanguage".to_string()), + target: format!("literal://string:{}", resolve_lang), + }); + } + + if let Some(node_kind) = &prop.node_kind { + // Ensure node_kind is a valid URI - prefix bare names with sh:// + // e.g. "IRI" -> "sh://IRI", "Literal" -> "sh://Literal" + let node_kind_uri = if node_kind.contains("://") { + node_kind.clone() + } else { + format!("sh://{}", node_kind) + }; + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("sh://nodeKind".to_string()), + target: node_kind_uri, + }); + } + + if let Some(local) = prop.local { + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("ad4m://local".to_string()), + target: format!("literal://{}", local), + }); + } + + // Property-level actions (setter, adder, remover) + if !prop.setter.is_empty() { + let setter_json = + serde_json::to_string(&prop.setter).unwrap_or_else(|_| "[]".to_string()); + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("ad4m://setter".to_string()), + target: format!("literal://string:{}", setter_json), + }); + } + + if !prop.adder.is_empty() { + let adder_json = + serde_json::to_string(&prop.adder).unwrap_or_else(|_| "[]".to_string()); + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("ad4m://adder".to_string()), + target: format!("literal://string:{}", adder_json), + }); + } + + if !prop.remover.is_empty() { + let remover_json = + serde_json::to_string(&prop.remover).unwrap_or_else(|_| "[]".to_string()); + links.push(Link { + source: prop_shape_uri.clone(), + predicate: Some("ad4m://remover".to_string()), + target: format!("literal://string:{}", remover_json), + }); + } + } + + Ok(links) +} +/// Extract namespace from URI (e.g., "recipe://Recipe" -> "recipe://") +/// Matches TypeScript SHACLShape.ts extractNamespace() behavior +pub fn extract_namespace(uri: &str) -> Result { + // Handle protocol-style URIs (://ending) - for AD4M-style URIs like "recipe://Recipe" + // We want just the scheme + "://" part + if let Some(scheme_pos) = uri.find("://") { + let after_scheme = &uri[scheme_pos + 3..]; + + // If nothing after scheme or only simple local name (no / or #), return just scheme:// + if !after_scheme.contains('/') && !after_scheme.contains('#') { + return Ok(uri[..scheme_pos + 3].to_string()); + } + } + + // Handle hash fragments (e.g., "http://example.com/ns#Recipe" -> "http://example.com/ns#") + if let Some(hash_pos) = uri.rfind('#') { + return Ok(uri[..hash_pos + 1].to_string()); + } + + // Handle slash-based paths (e.g., "http://example.com/ns/Recipe" -> "http://example.com/ns/") + if let Some(scheme_pos) = uri.find("://") { + let after_scheme = &uri[scheme_pos + 3..]; + if let Some(last_slash) = after_scheme.rfind('/') { + return Ok(uri[..scheme_pos + 3 + last_slash + 1].to_string()); + } + } + + // Error: malformed URI without proper namespace structure + Err(anyhow::anyhow!( + "Cannot extract namespace from malformed URI: '{}'", + uri + )) +} + +/// Extract local name from URI (e.g., "recipe://name" -> "name") +fn extract_local_name(uri: &str) -> String { + // Find the last occurrence of namespace delimiters: '#', ':', or '/' + // This handles URIs like "http://example.com/ns#name" or "prefix:name" + let last_hash = uri.rfind('#'); + let last_colon = uri.rfind(':'); + let last_slash = uri.rfind('/'); + + // Find the rightmost delimiter position + let delimiter_pos = [last_hash, last_colon, last_slash] + .iter() + .filter_map(|&pos| pos) + .max(); + + match delimiter_pos { + Some(pos) => { + let local_name = &uri[pos + 1..]; + if local_name.is_empty() { + "unknown".to_string() + } else { + local_name.to_string() + } + } + None => { + // No delimiter found, return the whole URI if non-empty + if uri.is_empty() { + "unknown".to_string() + } else { + uri.to_string() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_namespace() { + // AD4M-style URIs (scheme://LocalName) -> just scheme:// + assert_eq!(extract_namespace("recipe://Recipe").unwrap(), "recipe://"); + assert_eq!(extract_namespace("simple://Test").unwrap(), "simple://"); + + // W3C-style URIs with hash fragments -> include the hash + assert_eq!( + extract_namespace("http://example.com/ns#Recipe").unwrap(), + "http://example.com/ns#" + ); + + // W3C-style URIs with slash paths -> include trailing slash + assert_eq!( + extract_namespace("http://example.com/ns/Recipe").unwrap(), + "http://example.com/ns/" + ); + } + + #[test] + fn test_extract_local_name() { + assert_eq!(extract_local_name("recipe://name"), "name"); + assert_eq!( + extract_local_name("http://example.com/property"), + "property" + ); + assert_eq!(extract_local_name("simple://test/path/item"), "item"); + } + + #[test] + fn test_parse_shacl_basic() { + let shacl_json = r#"{ + "target_class": "recipe://Recipe", + "properties": [ + { + "path": "recipe://name", + "name": "name", + "datatype": "xsd://string", + "min_count": 1, + "max_count": 1, + "writable": true, + "resolve_language": "literal" + } + ] + }"#; + + let links = parse_shacl_to_links(shacl_json, "Recipe").unwrap(); + + // Should have: class definition (4) + property shape (7) = 11 links minimum + // Note: ad4m://has_subject_class link is NOT created here - it's created by add_sdna() + assert!(links.len() >= 11); + + // Check for key links (note: ad4m://self -> literal://string:Recipe is NOT here) + assert!(links.iter().any(|l| l.source == "recipe://RecipeShape" + && l.predicate == Some("sh://targetClass".to_string()))); + assert!(links + .iter() + .any(|l| l.source == "recipe://Recipe.name" + && l.predicate == Some("sh://path".to_string()))); + } + + #[test] + fn test_parse_shacl_with_actions() { + let shacl_json = r#"{ + "target_class": "recipe://Recipe", + "constructor_actions": [ + {"action": "addLink", "source": "this", "predicate": "recipe://name", "target": "literal://string:uninitialized"} + ], + "destructor_actions": [ + {"action": "removeLink", "source": "this", "predicate": "recipe://name", "target": "*"} + ], + "properties": [ + { + "path": "recipe://name", + "name": "name", + "datatype": "xsd://string", + "min_count": 1, + "max_count": 1, + "writable": true, + "setter": [{"action": "setSingleTarget", "source": "this", "predicate": "recipe://name", "target": "value"}] + }, + { + "path": "recipe://ingredient", + "name": "ingredients", + "node_kind": "IRI", + "adder": [{"action": "addLink", "source": "this", "predicate": "recipe://ingredient", "target": "value"}], + "remover": [{"action": "removeLink", "source": "this", "predicate": "recipe://ingredient", "target": "value"}] + } + ] + }"#; + + let links = parse_shacl_to_links(shacl_json, "Recipe").unwrap(); + + // Check for constructor action link + assert!( + links.iter().any(|l| l.source == "recipe://RecipeShape" + && l.predicate == Some("ad4m://constructor".to_string()) + && l.target.starts_with("literal://string:")), + "Missing constructor action link" + ); + + // Check for destructor action link + assert!( + links.iter().any(|l| l.source == "recipe://RecipeShape" + && l.predicate == Some("ad4m://destructor".to_string()) + && l.target.starts_with("literal://string:")), + "Missing destructor action link" + ); + + // Check for property setter action link + assert!( + links.iter().any(|l| l.source == "recipe://Recipe.name" + && l.predicate == Some("ad4m://setter".to_string()) + && l.target.starts_with("literal://string:")), + "Missing setter action link" + ); + + // Check for collection adder action link + assert!( + links + .iter() + .any(|l| l.source == "recipe://Recipe.ingredients" + && l.predicate == Some("ad4m://adder".to_string()) + && l.target.starts_with("literal://string:")), + "Missing adder action link" + ); + + // Check for collection remover action link + assert!( + links + .iter() + .any(|l| l.source == "recipe://Recipe.ingredients" + && l.predicate == Some("ad4m://remover".to_string()) + && l.target.starts_with("literal://string:")), + "Missing remover action link" + ); + } + + #[test] + fn test_parse_flow_basic() { + let flow_json = r#"{ + "name": "TODO", + "namespace": "todo://", + "flowable": "any", + "start_action": [ + {"action": "addLink", "source": "this", "predicate": "todo://state", "target": "todo://ready"} + ], + "states": [ + { + "name": "ready", + "value": 0.0, + "state_check": {"predicate": "todo://state", "target": "todo://ready"} + }, + { + "name": "done", + "value": 1.0, + "state_check": {"predicate": "todo://state", "target": "todo://done"} + } + ], + "transitions": [ + { + "action_name": "Complete", + "from_state": "ready", + "to_state": "done", + "actions": [ + {"action": "addLink", "source": "this", "predicate": "todo://state", "target": "todo://done"}, + {"action": "removeLink", "source": "this", "predicate": "todo://state", "target": "todo://ready"} + ] + } + ] + }"#; + + let links = parse_flow_to_links(flow_json, "TODO").unwrap(); + + // Check for flow type link + assert!( + links.iter().any(|l| l.source == "todo://TODOFlow" + && l.predicate == Some("rdf://type".to_string()) + && l.target == "ad4m://Flow"), + "Missing flow type link" + ); + + // Check for flowable link + assert!( + links.iter().any(|l| l.source == "todo://TODOFlow" + && l.predicate == Some("ad4m://flowable".to_string()) + && l.target == "ad4m://any"), + "Missing flowable link" + ); + + // Check for start action link + assert!( + links.iter().any(|l| l.source == "todo://TODOFlow" + && l.predicate == Some("ad4m://startAction".to_string()) + && l.target.starts_with("literal://string:")), + "Missing start action link" + ); + + // Check for state links + assert!( + links.iter().any(|l| l.source == "todo://TODOFlow" + && l.predicate == Some("ad4m://hasState".to_string()) + && l.target == "todo://TODO.ready"), + "Missing ready state link" + ); + + assert!( + links.iter().any(|l| l.source == "todo://TODOFlow" + && l.predicate == Some("ad4m://hasState".to_string()) + && l.target == "todo://TODO.done"), + "Missing done state link" + ); + + // Check for transition link + assert!( + links.iter().any(|l| l.source == "todo://TODOFlow" + && l.predicate == Some("ad4m://hasTransition".to_string()) + && l.target == "todo://TODO.readyTodone"), + "Missing transition link" + ); + + // Check for transition action name + assert!( + links.iter().any(|l| l.source == "todo://TODO.readyTodone" + && l.predicate == Some("ad4m://actionName".to_string())), + "Missing action name link" + ); + } + + #[test] + fn test_parse_flow_with_link_pattern_flowable() { + let flow_json = r#"{ + "name": "Approval", + "namespace": "approval://", + "flowable": {"predicate": "rdf://type", "target": "approval://Document"}, + "states": [], + "transitions": [] + }"#; + + let links = parse_flow_to_links(flow_json, "Approval").unwrap(); + + // Check for flowable link with pattern (not "any") + let flowable_link = links + .iter() + .find(|l| { + l.source == "approval://ApprovalFlow" + && l.predicate == Some("ad4m://flowable".to_string()) + }) + .expect("Missing flowable link"); + + // Should be a literal with JSON, not "ad4m://any" + assert!( + flowable_link.target.starts_with("literal://string:"), + "Flowable should be encoded as literal JSON" + ); + assert!( + flowable_link.target.contains("predicate"), + "Flowable literal should contain predicate" + ); + } +} diff --git a/rust-executor/src/perspectives/shacl_to_prolog.rs b/rust-executor/src/perspectives/shacl_to_prolog.rs new file mode 100644 index 000000000..aee1d965a --- /dev/null +++ b/rust-executor/src/perspectives/shacl_to_prolog.rs @@ -0,0 +1,410 @@ +//! SHACL to Prolog backward compatibility module +//! +//! This module generates Prolog facts from SHACL links, enabling backward compatibility +//! with existing Prolog-based SDNA queries (like infer() and template matching) when +//! classes are defined using SHACL-only (without Prolog code). +//! +//! This is a transitional feature - once all consumers are updated to use SHACL queries +//! directly, this module can be deprecated. + +use crate::types::DecoratedLinkExpression; +use std::collections::{HashMap, HashSet}; + +/// Generate Prolog facts from SHACL links for backward compatibility. +/// +/// This allows infer() queries and template matching to work with SHACL-only classes. +/// Only generates facts for classes that don't have original Prolog code. +/// +/// # Arguments +/// * `all_links` - All links in the perspective +/// * `seen_subject_classes` - Map of class names to their properties (including "code" for Prolog) +/// +/// # Returns +/// Vector of Prolog fact strings +pub fn generate_prolog_facts_from_shacl( + all_links: &[DecoratedLinkExpression], + seen_subject_classes: &HashMap>, +) -> Vec { + let mut lines = Vec::new(); + + // First pass: collect shape → class mappings and class info + let mut shape_to_class: HashMap = HashMap::new(); + let mut class_shapes: HashMap = HashMap::new(); + + for link_expression in all_links { + let link = &link_expression.data; + // sh://targetClass links map shapes to classes + if link.predicate == Some("sh://targetClass".to_string()) { + let shape_uri = &link.source; + let class_uri = &link.target; + let class_name = extract_local_name(class_uri); + + if !class_name.is_empty() { + // Only generate SHACL→Prolog facts for classes WITHOUT original Prolog code + // Classes with original Prolog use their own predicates and class identifiers + let has_original_prolog = seen_subject_classes + .get(&class_name) + .and_then(|props| props.get("code")) + .map(|code| !code.trim().is_empty()) + .unwrap_or(false); + + if !has_original_prolog { + shape_to_class.insert(shape_uri.clone(), class_name.clone()); + class_shapes.insert(class_name, shape_uri.clone()); + } + } + } + } + + // Second pass: collect properties for each shape + let mut shape_properties: HashMap> = HashMap::new(); + let mut property_to_shape: HashMap = HashMap::new(); + + for link_expression in all_links { + let link = &link_expression.data; + // sh://property links connect shapes to property shapes + if link.predicate == Some("sh://property".to_string()) { + let shape_uri = &link.source; + let prop_shape_uri = &link.target; + + if shape_to_class.contains_key(shape_uri) { + shape_properties + .entry(shape_uri.clone()) + .or_default() + .push(prop_shape_uri.clone()); + property_to_shape.insert(prop_shape_uri.clone(), shape_uri.clone()); + } + } + } + + // Third pass: collect property names and setters + let mut prop_shape_to_name: HashMap = HashMap::new(); + let mut prop_has_setter: HashSet = HashSet::new(); + let mut prop_is_collection: HashSet = HashSet::new(); + let mut shape_has_constructor: HashSet = HashSet::new(); + + for link_expression in all_links { + let link = &link_expression.data; + + // sh://path links give property names + if link.predicate == Some("sh://path".to_string()) { + let prop_shape_uri = &link.source; + let path_uri = &link.target; + let prop_name = extract_local_name(path_uri); + + if !prop_name.is_empty() { + prop_shape_to_name.insert(prop_shape_uri.clone(), prop_name); + } + } + + // ad4m://setter links indicate writable properties + if link.predicate == Some("ad4m://setter".to_string()) { + prop_has_setter.insert(link.source.clone()); + } + + // ad4m://CollectionShape type indicates a collection + if link.predicate == Some("rdf://type".to_string()) + && link.target == "ad4m://CollectionShape" + { + prop_is_collection.insert(link.source.clone()); + } + + // ad4m://constructor links indicate the shape has a constructor + if link.predicate == Some("ad4m://constructor".to_string()) { + shape_has_constructor.insert(link.source.clone()); + } + } + + // Generate Prolog facts for each SHACL class + for (class_name, shape_uri) in &class_shapes { + let shape_id = generate_prolog_safe_id(class_name); + + // subject_class/2 fact + lines.push(format!("subject_class(\"{}\", {}).", class_name, shape_id)); + + // Generate property facts + if let Some(prop_shapes) = shape_properties.get(shape_uri) { + for prop_shape in prop_shapes { + if let Some(prop_name) = prop_shape_to_name.get(prop_shape) { + if prop_is_collection.contains(prop_shape) { + // collection/2 fact + lines.push(format!("collection({}, \"{}\").", shape_id, prop_name)); + + // Collection operations - always generate adder, remover, and setter + // These are required for template object matching queries + lines.push(format!( + "collection_adder({}, \"{}\", _).", + shape_id, prop_name + )); + lines.push(format!( + "collection_remover({}, \"{}\", _).", + shape_id, prop_name + )); + lines.push(format!( + "collection_setter({}, \"{}\", _).", + shape_id, prop_name + )); + } else { + // property/2 fact + lines.push(format!("property({}, \"{}\").", shape_id, prop_name)); + + // property_setter/3 if writable + if prop_has_setter.contains(prop_shape) { + lines.push(format!( + "property_setter({}, \"{}\", _).", + shape_id, prop_name + )); + } + } + } + } + } + + // constructor/2 if shape has constructor + if shape_has_constructor.contains(shape_uri) { + lines.push(format!("constructor({}, _).", shape_id)); + } + + // Generate instance/2 rule for SHACL-based classes + let instance_conditions = collect_instance_conditions( + shape_uri, + &shape_properties, + &prop_shape_to_name, + all_links, + ); + + if !instance_conditions.is_empty() { + // Use OR (;) to check if ANY of the properties exist + let condition_str = instance_conditions.join("; "); + lines.push(format!( + "instance({}, Base) :- {}.", + shape_id, condition_str + )); + } else { + // No properties found - generate a permissive rule that matches any base + lines.push(format!("instance({}, _).", shape_id)); + } + } + + lines +} + +/// Extract the local name from a URI. +/// Examples: +/// - "recipe://name" -> "name" +/// - "https://example.com/vocab#term" -> "term" +/// - "flux://Channel" -> "Channel" +fn extract_local_name(uri: &str) -> String { + uri.split("://") + .last() + .unwrap_or(uri) + .split('/') + .last() + .unwrap_or(uri) + .split('#') + .last() + .unwrap_or(uri) + .to_string() +} + +/// Generate a Prolog-safe identifier from a class name. +/// Sanitizes: lowercase, replace non-alphanumeric with '_', collapse underscores. +fn generate_prolog_safe_id(class_name: &str) -> String { + let sanitized_name = class_name + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) + .collect::() + .split('_') + .filter(|s| !s.is_empty()) + .collect::>() + .join("_"); + format!("shacl_{}", sanitized_name) +} + +/// Collect instance conditions for a shape (predicates that can identify an instance). +fn collect_instance_conditions( + shape_uri: &str, + shape_properties: &HashMap>, + prop_shape_to_name: &HashMap, + all_links: &[DecoratedLinkExpression], +) -> Vec { + let mut conditions = Vec::new(); + + if let Some(prop_shapes) = shape_properties.get(shape_uri) { + for prop_shape in prop_shapes { + if prop_shape_to_name.contains_key(prop_shape) { + // Find the path predicate for this property + for link_expression in all_links { + let link = &link_expression.data; + if link.predicate == Some("sh://path".to_string()) && &link.source == prop_shape + { + let predicate = &link.target; + conditions.push(format!("triple(Base, \"{}\", _)", predicate)); + break; + } + } + } + } + } + + conditions +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{DecoratedExpressionProof, Link}; + + fn make_link(source: &str, predicate: &str, target: &str) -> DecoratedLinkExpression { + DecoratedLinkExpression { + author: "did:key:test".to_string(), + timestamp: "2026-01-01T00:00:00Z".to_string(), + data: Link { + source: source.to_string(), + predicate: Some(predicate.to_string()), + target: target.to_string(), + }, + proof: DecoratedExpressionProof { + signature: "sig".to_string(), + key: "key".to_string(), + valid: Some(true), + invalid: None, + }, + status: None, + } + } + + #[test] + fn test_extract_local_name() { + assert_eq!(extract_local_name("recipe://name"), "name"); + assert_eq!(extract_local_name("flux://Channel"), "Channel"); + assert_eq!(extract_local_name("https://example.com/vocab#term"), "term"); + assert_eq!(extract_local_name("simple"), "simple"); + } + + #[test] + fn test_generate_prolog_safe_id() { + assert_eq!(generate_prolog_safe_id("Recipe"), "shacl_recipe"); + assert_eq!(generate_prolog_safe_id("MyClass"), "shacl_myclass"); + assert_eq!( + generate_prolog_safe_id("Some-Class-Name"), + "shacl_some_class_name" + ); + } + + #[test] + fn test_generate_prolog_facts_empty_input() { + let links: Vec = vec![]; + let seen_classes: HashMap> = HashMap::new(); + + let facts = generate_prolog_facts_from_shacl(&links, &seen_classes); + assert!(facts.is_empty()); + } + + #[test] + fn test_generate_prolog_facts_basic_class() { + let links = vec![ + // Shape definition + make_link("recipe://RecipeShape", "rdf://type", "sh://NodeShape"), + make_link( + "recipe://RecipeShape", + "sh://targetClass", + "recipe://Recipe", + ), + // Property + make_link( + "recipe://RecipeShape", + "sh://property", + "recipe://Recipe.name", + ), + make_link("recipe://Recipe.name", "sh://path", "recipe://name"), + make_link("recipe://Recipe.name", "ad4m://setter", "literal://..."), + // Constructor + make_link( + "recipe://RecipeShape", + "ad4m://constructor", + "literal://...", + ), + ]; + + let seen_classes: HashMap> = HashMap::new(); + let facts = generate_prolog_facts_from_shacl(&links, &seen_classes); + + // Should generate subject_class, property, property_setter, constructor, instance + assert!(facts + .iter() + .any(|f| f.contains("subject_class(\"Recipe\","))); + assert!(facts.iter().any(|f| f.contains("property(shacl_recipe,"))); + assert!(facts + .iter() + .any(|f| f.contains("property_setter(shacl_recipe,"))); + assert!(facts + .iter() + .any(|f| f.contains("constructor(shacl_recipe,"))); + assert!(facts.iter().any(|f| f.contains("instance(shacl_recipe,"))); + } + + #[test] + fn test_skips_classes_with_prolog_code() { + let links = vec![make_link( + "recipe://RecipeShape", + "sh://targetClass", + "recipe://Recipe", + )]; + + // Class has existing Prolog code - should skip + let mut seen_classes: HashMap> = HashMap::new(); + let mut recipe_props = HashMap::new(); + recipe_props.insert("code".to_string(), "subject_class(...)".to_string()); + seen_classes.insert("Recipe".to_string(), recipe_props); + + let facts = generate_prolog_facts_from_shacl(&links, &seen_classes); + + // Should NOT generate facts for Recipe since it has Prolog code + assert!(!facts.iter().any(|f| f.contains("Recipe"))); + } + + #[test] + fn test_collection_facts() { + let links = vec![ + make_link( + "recipe://RecipeShape", + "sh://targetClass", + "recipe://Recipe", + ), + make_link( + "recipe://RecipeShape", + "sh://property", + "recipe://Recipe.items", + ), + make_link("recipe://Recipe.items", "sh://path", "recipe://items"), + make_link( + "recipe://Recipe.items", + "rdf://type", + "ad4m://CollectionShape", + ), + ]; + + let seen_classes: HashMap> = HashMap::new(); + let facts = generate_prolog_facts_from_shacl(&links, &seen_classes); + + // Should generate collection facts + assert!(facts.iter().any(|f| f.contains("collection(shacl_recipe,"))); + assert!(facts + .iter() + .any(|f| f.contains("collection_adder(shacl_recipe,"))); + assert!(facts + .iter() + .any(|f| f.contains("collection_remover(shacl_recipe,"))); + assert!(facts + .iter() + .any(|f| f.contains("collection_setter(shacl_recipe,"))); + } +} diff --git a/rust-executor/src/prolog_service/mod.rs b/rust-executor/src/prolog_service/mod.rs index 9bf9e56a8..d8934895c 100644 --- a/rust-executor/src/prolog_service/mod.rs +++ b/rust-executor/src/prolog_service/mod.rs @@ -48,7 +48,7 @@ pub enum PrologMode { // Set to Pooled for maximum query performance (multiple engines with caching) // Set to SdnaOnly for SDNA introspection without link data (minimal memory + SDNA queries work) // Set to Disabled to turn off Prolog completely -pub static PROLOG_MODE: PrologMode = PrologMode::SdnaOnly; +pub static PROLOG_MODE: PrologMode = PrologMode::Disabled; #[derive(Clone)] pub struct PrologService { @@ -359,14 +359,14 @@ impl PrologService { ) -> Result { use deno_core::anyhow::anyhow; - // Check if Prolog is disabled + // Check if Prolog is disabled - return empty matches but log warning if PROLOG_MODE == PrologMode::Disabled { log::warn!( - "⚠️ Prolog query received but Prolog is DISABLED (perspective: {}, query: {})", + "Prolog query received but Prolog is DISABLED (perspective: {}, query: {})", perspective_id, query ); - return Err(anyhow!("Prolog is disabled")); + return Ok(QueryResolution::Matches(vec![])); } // Ensure engine is up to date @@ -415,14 +415,14 @@ impl PrologService { ) -> Result { use deno_core::anyhow::anyhow; - // Check if Prolog is disabled + // Check if Prolog is disabled - return empty matches but log warning if PROLOG_MODE == PrologMode::Disabled { log::warn!( - "⚠️ Prolog subscription query received but Prolog is DISABLED (perspective: {}, query: {})", + "Prolog subscription query received but Prolog is DISABLED (perspective: {}, query: {})", perspective_id, query ); - return Err(anyhow!("Prolog is disabled")); + return Ok(QueryResolution::Matches(vec![])); } // Ensure engine is up to date diff --git a/rust-executor/src/surreal_service/mod.rs b/rust-executor/src/surreal_service/mod.rs index 7eae4b223..fbbc721ae 100644 --- a/rust-executor/src/surreal_service/mod.rs +++ b/rust-executor/src/surreal_service/mod.rs @@ -994,6 +994,59 @@ impl SurrealDBService { Ok(vec![]) } + + /// Get all links matching a specific predicate where source ends with the given suffix + /// + /// # Arguments + /// * `_perspective_uuid` - UUID of the perspective (unused, for API consistency) + /// * `predicate` - Predicate URI to filter by + /// * `source_suffix` - Suffix that the source field must end with + /// + /// # Returns + /// * `Ok(Vec)` - All matching links + /// * `Err(Error)` - Database query error + pub async fn get_links_by_predicate_and_source_suffix( + &self, + _perspective_uuid: &str, + predicate: &str, + source_suffix: &str, + ) -> Result, Error> { + let query = + "SELECT * FROM link WHERE predicate = $predicate AND string::ends_with(source, $suffix)"; + let results = self + .db + .query(query) + .bind(("predicate", predicate.to_string())) + .bind(("suffix", source_suffix.to_string())) + .await?; + + let mut response = results; + let result: SurrealValue = response.take(0)?; + + let json_string = serde_json::to_string(&result)?; + let json_value: Value = serde_json::from_str(&json_string)?; + let unwrapped = unwrap_surreal_json(json_value); + + if let Value::Array(arr) = unwrapped { + let mut links: Vec = Vec::new(); + for value in arr { + match serde_json::from_value::(value.clone()) { + Ok(surreal_link) => { + links.push(surreal_link.into()); + } + Err(e) => { + warn!( + "Failed to deserialize SurrealLink in get_links_by_predicate_and_source_suffix: {}. Offending value: {}", + e, value + ); + } + } + } + return Ok(links); + } + + Ok(vec![]) + } } #[cfg(test)] diff --git a/tests/js/sdna/subject.pl b/tests/js/sdna/subject.pl index b5e7074f1..7589d8181 100644 --- a/tests/js/sdna/subject.pl +++ b/tests/js/sdna/subject.pl @@ -14,29 +14,20 @@ property_getter(c, Base, "title", Value) :- triple(Base, "todo://has_title", Value). property_setter(c, "title", '[{action: "setSingleTarget", source: "this", predicate: "todo://has_title", target: "value"}]'). -property(c, "isLiked"). -property_getter(c, Base, "isLiked", Value) :- triple(Base, "flux://has_reaction", "flux://thumbsup"), Value = true. - collection(c, "comments"). collection_getter(c, Base, "comments", List) :- findall(C, triple(Base, "todo://comment", C), List). -collection_adder(c, "commentss", '[{action: "addLink", source: "this", predicate: "todo://comment", target: "value"}]'). -collection_remover(c, "commentss", '[{action: "removeLink", source: "this", predicate: "todo://comment", target: "value"}]'). -collection_setter(c, "commentss", '[{action: "collectionSetter", source: "this", predicate: "todo://comment", target: "value"}]'). +collection_adder(c, "comments", '[{action: "addLink", source: "this", predicate: "todo://comment", target: "value"}]'). +collection_remover(c, "comments", '[{action: "removeLink", source: "this", predicate: "todo://comment", target: "value"}]'). +collection_setter(c, "comments", '[{action: "collectionSetter", source: "this", predicate: "todo://comment", target: "value"}]'). collection(c, "entries"). collection_getter(c, Base, "entries", List) :- findall(C, triple(Base, "flux://entry_type", C), List). -collection_adder(c, "entriess", '[{action: "addLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). -collection_remover(c, "entriess", '[{action: "removeLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). -collection_setter(c, "entriess", '[{action: "collectionSetter", source: "this", predicate: "flux://entry_type", target: "value"}]'). +collection_adder(c, "entries", '[{action: "addLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). +collection_remover(c, "entries", '[{action: "removeLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). +collection_setter(c, "entries", '[{action: "collectionSetter", source: "this", predicate: "flux://entry_type", target: "value"}]'). collection(c, "messages"). collection_getter(c, Base, "messages", List) :- setof(Target, (triple(Base, "flux://entry_type", Target), instance(OtherClass, Target), subject_class("Message", OtherClass)), List). -collection_adder(c, "messagess", '[{action: "addLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). -collection_remover(c, "messagess", '[{action: "removeLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). -collection_setter(c, "messagess", '[{action: "collectionSetter", source: "this", predicate: "flux://entry_type", target: "value"}]'). - -collection(c, "likedMessages"). -collection_getter(c, Base, "likedMessages", List) :- setof(Target, (triple(Base, "flux://entry_type", Target), triple(Target, "flux://has_reaction", "flux://thumbsup")), List). -collection_adder(c, "likedMessagess", '[{action: "addLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). -collection_remover(c, "likedMessagess", '[{action: "removeLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). -collection_setter(c, "likedMessagess", '[{action: "collectionSetter", source: "this", predicate: "flux://entry_type", target: "value"}]'). +collection_adder(c, "messages", '[{action: "addLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). +collection_remover(c, "messages", '[{action: "removeLink", source: "this", predicate: "flux://entry_type", target: "value"}]'). +collection_setter(c, "messages", '[{action: "collectionSetter", source: "this", predicate: "flux://entry_type", target: "value"}]'). diff --git a/tests/js/tests/prolog-and-literals.test.ts b/tests/js/tests/prolog-and-literals.test.ts index a7c57e592..9c0c0255b 100644 --- a/tests/js/tests/prolog-and-literals.test.ts +++ b/tests/js/tests/prolog-and-literals.test.ts @@ -64,223 +64,30 @@ describe("Prolog + Literals", () => { expect(result!.isInitialized).to.be.true }) - describe("Subjects", () => { + describe("Subjects (SHACL-based API)", () => { let perspective: PerspectiveProxy | null = null before(async () => { perspective = await ad4m!.perspective.add("test") // for test debugging: //console.log("UUID: " + perspective.uuid) - - let classes = await perspective.subjectClasses(); - expect(classes.length).to.equal(0) - - let sdna = readFileSync("./sdna/subject.pl").toString() - await perspective.addSdna("Todo", sdna, "subject_class") - - let retrievedSdna = await perspective.getSdna() - expect(retrievedSdna).to.deep.equal([sdna]) - }) - - it("should find the TODO subject class from the test SDNA", async () => { - let classes = await perspective!.subjectClasses(); - - expect(classes.length).to.equal(1) - expect(classes[0]).to.equal("Todo") - }) - - it("should be able to construct a subject instance from a literal", async () => { - let root = Literal.from("construct test").toUrl() - expect(await perspective!.createSubject("Todo", root)).to.not.be.undefined - expect(await perspective!.isSubjectInstance(root, "Todo")).to.not.be.false - }) - - it("can get subject instance proxy via class string", async () => { - let root = Literal.from("get proxy test").toUrl() - await perspective!.createSubject("Todo", root) - let subject = await perspective!.getSubjectProxy(root, "Todo") as unknown as Subject - expect(subject).to.not.be.undefined - expect(subject).to.have.property("state") - expect(subject).to.have.property("setState") - expect(subject).to.have.property("title") - }) - - describe("with an instance", () => { - let subject: Subject | null = null - - before(async () => { - let root = Literal.from("construct test").toUrl() - subject = await perspective!.createSubject("Todo", root) as unknown as Subject - }) - - it("should be able to read a property as JS property", async () => { - //@ts-ignore - expect(await subject.state).to.equal("todo://ready") - }) - - it("should be able to set a property with JS setter method", async () => { - //@ts-ignore - await subject.setState("todo://done") - //@ts-ignore - expect(await subject.state).to.equal("todo://done") - }) - - it("should work with a property that is not set initially and that auto-resolves", async () => { - //@ts-ignore - expect(await subject.title).to.be.undefined - - let title = "test title" - //@ts-ignore - await subject.setTitle(title) - //@ts-ignore - expect(await subject.title).to.equal(title) - }) - - it("should be able to get collections as arrays", async () => { - //@ts-ignore - expect(await subject.comments).to.be.an("array") - //@ts-ignore - expect(await subject.comments).to.be.empty - - let c1 = Literal.from("comment 1").toUrl() - await perspective!.add(new Link({ - source: subject!.baseExpression, - predicate: "todo://comment", - target: c1 - })) - - //@ts-ignore - expect(await subject.comments).to.deep.equal([c1]) - - let c2 = Literal.from("comment 2").toUrl() - await perspective!.add(new Link({ - source: subject!.baseExpression, - predicate: "todo://comment", - target: c2 - })) - - //@ts-ignore - expect(await subject.comments).to.deep.equal([c1, c2]) - }) - - it("should be able to add to collections", async () => { - let commentLinks = await perspective!.get(new LinkQuery({ - source: subject!.baseExpression, - predicate: "todo://comment" - })) - for(let link of commentLinks) { - await perspective!.remove(link) - } - - //@ts-ignore - expect(await subject.comments).to.be.empty - - let c1 = Literal.from("new comment 1").toUrl() - let c2 = Literal.from("new comment 2").toUrl() - - //@ts-ignore - await subject.addComments(c1) - //@ts-ignore - expect(await subject.comments).to.deep.equal([c1]) - - //@ts-ignore - await subject.addComments(c2) - //@ts-ignore - expect(await subject.comments).to.deep.equal([c1, c2]) - }) - - it("should be able to get all subject instance of a given class", async () => { - let todos = await perspective!.getAllSubjectInstances("Todo") as unknown as Subject[] - expect(todos.length).to.equal(2) - //@ts-ignore - expect(await todos[1].state).to.exist - }) - - it("should create a subject with initial values", async () => { - let root = Literal.from("initial values test").toUrl() - const initialValues = { - title: "Initial Title", - state: "todo://done" - } - await perspective!.createSubject("Todo", root, initialValues) - let subject = await perspective!.getSubjectProxy(root, "Todo") as unknown as Subject - - //@ts-ignore - expect(await subject.title).to.equal("Initial Title") - //@ts-ignore - expect(await subject.state).to.equal("todo://done") - }) }) - describe("TypeScript compatibility", () => { - - // This class mathces the SDNA in ./sdna/subject.pl - class Todo { - state: string = "" - title: string = "" - comments: string[] = [] - - setState(state: string) {} - setTitle(title: string) {} - addComments(comment: string) {} - setCollectionComments(comment: string) {} - } - - // This class doesn not match the SDNA in ./sdna/subject.pl - class UnknownSubject { - name: string = "" - x: string = "" - - setTop(top: string) {} - } + // REMOVED: Legacy Prolog SDNA test - Prolog SDNA is superseded by SHACL + // The addSdna API now accepts optional sdnaCode with shaclJson being the primary input. + // See "SDNA creation decorators" tests below for the modern SHACL-based API. - // This class is like Todo, but has a setter that - // is not defined in the SDNA (-> should not match) - class AlmostTodo { - state: string = "" - title: string = "" - comments: string[] = [] - - setState(state: string) {} - setTitle(title: string) {} - addComment(comment: string) {} - setTop(top: string) {} - } - - let todo: Todo = new Todo() - let unknown: UnknownSubject = new UnknownSubject() - let almostTodo: AlmostTodo = new AlmostTodo() - - it("can find subject classes mapping to JS objects", async () => { - let todoClasses = await perspective!.subjectClassesByTemplate(todo) - expect(todoClasses).to.include("Todo") - expect(todoClasses.length).to.equal(1) - - let unknownClasses = await perspective!.subjectClassesByTemplate(unknown) - expect(unknownClasses).to.be.empty - - let almostTodoClasses = await perspective!.subjectClassesByTemplate(almostTodo) - expect(almostTodoClasses).to.be.empty - }) - - it("can find subject and create instances in a type-safe way", async () => { - // PerspectiveProxe.getAllSubjectInstances() is a generic that returns - // an array of the given type. - let todos = await perspective!.getAllSubjectInstances(todo) - - // todos is an array of Todo objects - // note how we don't need @ts-ignore here: - expect(todos.length).to.equal(3) - expect(await todos[1].state).to.exist - }) - - }) + // NOTE: Legacy Subject proxy tests removed in SHACL migration PR. + // The Subject proxy API (Subject.init(), getSubjectProxy()) requires Prolog queries + // and has been superseded by the Ad4mModel API which is Prolog-free and SHACL-native. + // Production code (Flux) uses Ad4mModel exclusively. + // See "SDNA creation decorators" tests below for the modern API. describe("SDNA creation decorators", () => { @ModelOptions({ name: "Message" }) - class Message { + class Message extends Ad4mModel { @Flag({ through: "ad4m://type", value: "ad4m://message" @@ -292,9 +99,8 @@ describe("Prolog + Literals", () => { @Optional({ through: "todo://state", - initial: "todo://ready", }) - body: string = "" + body?: string } // This class matches the SDNA in ./sdna/subject.pl @@ -302,7 +108,7 @@ describe("Prolog + Literals", () => { @ModelOptions({ name: "Todo" }) - class Todo { + class Todo extends Ad4mModel { // Setting this member "subjectConstructer" allows for adding custom // actions that will be run when a subject is constructed. // @@ -327,27 +133,19 @@ describe("Prolog + Literals", () => { @InstanceQuery({where: { state: "todo://done" }}) static async allDone(perspective: PerspectiveProxy): Promise { return [] } - @InstanceQuery({ prologCondition: 'triple("ad4m://self", _, Instance)'}) - static async allSelf(perspective: PerspectiveProxy): Promise { return [] } - //@ts-ignore @Property({ through: "todo://state", - initial: "todo://ready", + initial: "todo://ready" }) - state: string = "" + state!: string @Optional({ through: "todo://has_title", writable: true, resolveLanguage: "literal" }) - title: string = "" - - @ReadOnly({ - prologGetter: `triple(Base, "flux://has_reaction", "flux://thumbsup"), Value = true` - }) - isLiked: boolean = false + title?: string @Collection({ through: "todo://comment" }) comments: string[] = [] @@ -360,14 +158,20 @@ describe("Prolog + Literals", () => { where: { isInstance: Message } }) messages: string[] = [] - - @Collection({ - through: "flux://entry_type", - where: { prologCondition: `triple(Target, "flux://has_reaction", "flux://thumbsup")` } - }) - likedMessages: string[] = [] } + before(async () => { + // Register SHACL SDNA once for all tests in this block + await perspective!.ensureSDNASubjectClass(Todo) + }) + + it("should find the TODO subject class from the test SDNA", async () => { + let classes = await perspective!.subjectClasses(); + + expect(classes.length).to.equal(1) + expect(classes[0]).to.equal("Todo") + }) + it("should generate correct SDNA from a JS class", async () => { // @ts-ignore const { name, sdna } = Todo.generateSDNA(); @@ -382,24 +186,59 @@ describe("Prolog + Literals", () => { }) it("should be possible to use that class for type-safe interaction with subject instances", async () => { - // construct new subject intance + // Create additional todos for the following tests + // Todo 1: stays at initial "ready" state + let root1 = Literal.from("Ready todo").toUrl() + let todo1 = new Todo(perspective!, root1) + await todo1.save() + + // Todo 2 & 3: set to "done" state + let root2 = Literal.from("Done todo 1").toUrl() + let todo2 = new Todo(perspective!, root2) + await todo2.save() + todo2.state = "todo://done" + await todo2.update() + + let root3 = Literal.from("Done todo 2").toUrl() + let todo3 = new Todo(perspective!, root3) + await todo3.save() + todo3.state = "todo://done" + await todo3.update() + + // construct new subject intance using Ad4mModel API let root = Literal.from("Decorated class construction test").toUrl() - // get instance with type information - let todo = await perspective!.createSubject(new Todo(), root) - - expect(await perspective!.isSubjectInstance(root, new Todo())).to.not.be.false - let todo2 = await perspective!.getSubjectProxy(root, new Todo()) - expect(todo2).to.have.property("state") - expect(todo2).to.have.property("title") - expect(todo2).to.have.property("comments") - // @ts-ignore - await todo.setState("todo://review") - expect(await todo.state).to.equal("todo://review") + + let todo = new Todo(perspective!, root) + await todo.save() + + // Verify the instance was created with required links + const stateLinks = await perspective!.get(new LinkQuery({source: root, predicate: "todo://state"})) + expect(stateLinks.length).to.equal(1) + expect(stateLinks[0].data.target).to.equal("todo://ready") + + // Check name mapping + const nameMappingUrl = Literal.fromUrl(`literal://string:shacl://Todo`).toUrl() + const nameMappingLinks = await perspective!.get(new LinkQuery({source: nameMappingUrl})) + nameMappingLinks.forEach(link => console.log(" ", link.data.predicate, "->", link.data.target)) + + const isInstance = await perspective!.isSubjectInstance(root, Todo) + expect(isInstance).to.not.be.false + + // Ad4mModel API - use the todo instance directly (no need for getSubjectProxy) + expect(todo).to.have.property("state") + expect(todo).to.have.property("title") + expect(todo).to.have.property("comments") + + todo.state = "todo://review" + await todo.update() + const stateAfter = await todo.state + + expect(stateAfter).to.equal("todo://review") expect(await todo.comments).to.be.empty let comment = Literal.from("new comment").toUrl() - // @ts-ignore - await todo.addComments(comment) + todo.comments = [comment] + await todo.update() expect(await todo.comments).to.deep.equal([comment]) }) @@ -418,18 +257,9 @@ describe("Prolog + Literals", () => { expect(await todos[0].state).to.equal("todo://done") }) - it.skip("can retrieve matching instance through InstanceQuery(condition: ..)", async () => { - let todos = await Todo.allSelf(perspective!) - expect(todos.length).to.equal(0) - - todos = await Todo.all(perspective!) - let todo = todos[0] - //@ts-ignore - await perspective!.add(new Link({source: "ad4m://self", target: todo.baseExpression})) - - todos = await Todo.allSelf(perspective!) - expect(todos.length).to.equal(1) - }) + // REMOVED: InstanceQuery(condition: ..) test - required Prolog-only allSelf method + // The InstanceQuery with condition parameter required Prolog inference. + // Future: Could be reimplemented with SHACL-based query conditions via SurrealDB. it("can deal with properties that resolve the URI and create Expressions", async () => { let todos = await Todo.all(perspective!) @@ -462,8 +292,9 @@ describe("Prolog + Literals", () => { expect(await todo.title).to.be.undefined - // @ts-ignore - await todo.setTitle("new title") + // Use direct assignment + update() pattern (setters are stubs) + todo.title = "new title" + await todo.update() expect(await todo.title).to.equal("new title") //@ts-ignore @@ -492,26 +323,13 @@ describe("Prolog + Literals", () => { //console.log((await perspective!.getSdna())[1]) }) - it.skip("can use properties with custom getter prolog code", async () => { - let root = Literal.from("Custom getter test").toUrl() - let todo = await perspective!.createSubject(new Todo(), root) - - // @ts-ignore - const liked1 = await todo.isLiked - expect(liked1).to.be.undefined - - await perspective?.add(new Link({source: root, predicate: "flux://has_reaction", target: "flux://thumbsup"})) - - // @ts-ignore - const liked2 = await todo.isLiked - expect(liked2).to.be.true - }) + // REMOVED: Custom getter prolog code test - required Prolog-based property getters + // The isLiked property used custom Prolog code for computed values. + // Future: Could be reimplemented with SHACL-based computed properties or SurrealDB queries. describe("with Message subject class registered", () => { before(async () => { - // @ts-ignore - const { name, sdna } = Message.generateSDNA(); - await perspective!.addSdna(name, sdna, "subject_class") + await perspective!.ensureSDNASubjectClass(Message) }) afterEach(async () => { @@ -544,12 +362,12 @@ describe("Prolog + Literals", () => { it("can constrain collection entries through 'where' clause", async () => { let root = Literal.from("Collection where test").toUrl() - let todo = await perspective!.createSubject(new Todo(), root) - let messageEntry = Literal.from("test message").toUrl() - - // @ts-ignore - await todo.addEntries(messageEntry) + + // Create todo with entries already set + let todo = new Todo(perspective!, root) + todo.entries = [messageEntry] + await todo.save() let entries = await todo.entries expect(entries.length).to.equal(1) @@ -557,8 +375,11 @@ describe("Prolog + Literals", () => { let messageEntries = await todo.messages expect(messageEntries.length).to.equal(0) - await perspective!.createSubject(new Message(), messageEntry) - + let message = new Message(perspective!, messageEntry) + await message.save() + + // Refresh todo data to apply collection filtering + await todo.get() messageEntries = await todo.messages expect(messageEntries.length).to.equal(1) }) @@ -602,12 +423,6 @@ describe("Prolog + Literals", () => { @Collection({ through: "recipe://entries" }) entries: string[] = [] - @Collection({ - through: "recipe://entries", - where: { prologCondition: `triple(Target, "recipe://has_ingredient", "recipe://test")` } - }) - ingredients: string[] = [] - @Collection({ through: "recipe://comment" }) comments: string[] = [] @@ -641,9 +456,7 @@ describe("Prolog + Literals", () => { await ad4m!.perspective.remove(perspective.uuid) } perspective = await ad4m!.perspective.add("active-record-implementation-test") - // @ts-ignore - const { name, sdna } = Recipe.generateSDNA(); - await perspective!.addSdna(name, sdna, 'subject_class') + await perspective!.ensureSDNASubjectClass(Recipe) }) it("save() & get()", async () => { @@ -761,34 +574,20 @@ describe("Prolog + Literals", () => { expect(updatedRecipies.length).to.equal(2) }) - it.skip("can constrain collection entries through 'where' clause with prolog condition", async () => { - let root = Literal.from("Active record implementation collection test with where").toUrl(); - const recipe = new Recipe(perspective!, root); - - let recipeEntries = Literal.from("test recipes").toUrl(); - - recipe.entries = [recipeEntries]; - // @ts-ignore - recipe.comments = ['recipe://test', 'recipe://test1']; - recipe.name = "Collection test"; - - await recipe.save(); - - await perspective?.add(new Link({source: recipeEntries, predicate: "recipe://has_ingredient", target: "recipe://test"})); - - await recipe.get(); - - const recipe2 = new Recipe(perspective!, root); - - await recipe2.get(); - - expect(recipe2.ingredients.length).to.equal(1); - }) + // REMOVED: Collection 'where' clause with prolog condition test + // The ingredients property used Prolog-based where clause filtering. + // The next test demonstrates the modern approach using SurrealDB conditions. it("can constrain collection entries through 'where' clause with condition", async () => { // Define a Recipe model with condition filtering @ModelOptions({ name: "RecipeWithSurrealFilter" }) class RecipeWithSurrealFilter extends Ad4mModel { + @Flag({ + through: "ad4m://type", + value: "recipe://instance" + }) + type: string = "" + @Optional({ through: "recipe://name", resolveLanguage: "literal" @@ -809,6 +608,9 @@ describe("Prolog + Literals", () => { // Register the class await perspective!.ensureSDNASubjectClass(RecipeWithSurrealFilter); + + // Wait for SHACL metadata to be indexed + await sleep(500); let root = Literal.from("Active record surreal condition test").toUrl(); const recipe = new RecipeWithSurrealFilter(perspective!, root); @@ -2396,60 +2198,10 @@ describe("Prolog + Literals", () => { } }); - it.skip("should produce identical results with SurrealDB and Prolog subscriptions", async () => { - // 1. Setup subscriptions - const surrealCallback = sinon.fake(); - const prologCallback = sinon.fake(); - - // SurrealDB subscription (default) - const surrealBuilder = TestModel.query(perspective).where({ status: "active" }); - await surrealBuilder.subscribe(surrealCallback); - - // Prolog subscription (explicit) - const prologBuilder = TestModel.query(perspective).where({ status: "active" }).useSurrealDB(false); - await prologBuilder.subscribe(prologCallback); - - // 2. Add data - const startTime = Date.now(); - const count = 5; - - for (let i = 0; i < count; i++) { - const model = new TestModel(perspective); - model.name = `Item ${i}`; - model.status = "active"; - await model.save(); - } - - // 3. Wait for updates - // Give enough time for both to catch up - await sleep(2000); - - // 4. Verify results match - expect(surrealCallback.called).to.be.true; - expect(prologCallback.called).to.be.true; - - const surrealLastResult = surrealCallback.lastCall.args[0]; - const prologLastResult = prologCallback.lastCall.args[0]; - - expect(surrealLastResult.length).to.equal(count); - expect(prologLastResult.length).to.equal(count); - - // Sort by name to ensure order doesn't affect comparison - const sortByName = (a: TestModel, b: TestModel) => a.name.localeCompare(b.name); - surrealLastResult.sort(sortByName); - prologLastResult.sort(sortByName); - - for (let i = 0; i < count; i++) { - expect(surrealLastResult[i].name).to.equal(prologLastResult[i].name); - expect(surrealLastResult[i].status).to.equal(prologLastResult[i].status); - } - - console.log(`SurrealDB vs Prolog subscription parity check passed with ${count} items.`); - - // Cleanup - surrealBuilder.dispose(); - prologBuilder.dispose(); - }); + // REMOVED: SurrealDB vs Prolog parity test + // This test compared SurrealDB and Prolog subscription results. + // With SHACL migration, SurrealDB is now the primary query engine. + // Prolog subscriptions are deprecated - no need for parity testing. it("should demonstrate SurrealDB subscription performance", async () => { // Measure latency of update @@ -2907,8 +2659,7 @@ describe("Prolog + Literals", () => { await ad4m!.perspective.remove(perspective.uuid) } perspective = await ad4m!.perspective.add("getter-test") - const { name, sdna } = (BlogPost as any).generateSDNA(); - await perspective!.addSdna(name, sdna, 'subject_class') + await perspective!.ensureSDNASubjectClass(BlogPost) }); it("should evaluate getter for property", async () => { @@ -3250,7 +3001,10 @@ describe("Prolog + Literals", () => { }) - // skipped because only applies to prolog-pooled moded + // SKIPPED: Embedding cache tests - only applies to Prolog-pooled mode + // These tests verify embedding URL post-processing with Prolog infer() queries. + // With SHACL migration, embedding queries should use SurrealDB vector search instead. + // Keeping as reference for future SurrealDB vector embedding implementation. describe.skip('Embedding cache', () => { let perspective: PerspectiveProxy | null = null; const EMBEDDING_LANG = "QmzSYwdbqjGGbYbWJvdKA4WnuFwmMx3AsTfgg7EwbeNUGyE555c"; diff --git a/tests/js/tests/social-dna-flow.ts b/tests/js/tests/social-dna-flow.ts index 3fc9b5012..353635df1 100644 --- a/tests/js/tests/social-dna-flow.ts +++ b/tests/js/tests/social-dna-flow.ts @@ -1,4 +1,4 @@ -import { Link, LinkQuery, Literal } from "@coasys/ad4m"; +import { Link, LinkQuery, Literal, SHACLFlow } from "@coasys/ad4m"; import { TestContext } from './integration.test' import { expect } from "chai"; import { sleep } from "../utils/utils"; @@ -100,5 +100,141 @@ export default function socialDNATests(testContext: TestContext) { }) }) + + describe("SHACL-based TODO flow", () => { + it('can add SHACL flow and go through full TODO workflow', async () => { + const ad4mClient = testContext.ad4mClient! + + // Create perspective + const perspective = await ad4mClient.perspective.add("shacl-flow-test"); + expect(perspective.name).to.be.equal("shacl-flow-test"); + + // Create a SHACLFlow for TODO workflow + const todoFlow = new SHACLFlow('TODO', 'todo://'); + todoFlow.flowable = 'any'; + + // Define states + todoFlow.addState({ + name: 'ready', + value: 0, + stateCheck: { predicate: 'todo://state', target: 'todo://ready' } + }); + todoFlow.addState({ + name: 'doing', + value: 0.5, + stateCheck: { predicate: 'todo://state', target: 'todo://doing' } + }); + todoFlow.addState({ + name: 'done', + value: 1, + stateCheck: { predicate: 'todo://state', target: 'todo://done' } + }); + + // Define start action + todoFlow.startAction = [{ + action: 'addLink', + source: 'this', + predicate: 'todo://state', + target: 'todo://ready' + }]; + + // Define transitions + todoFlow.addTransition({ + actionName: 'Start', + fromState: 'ready', + toState: 'doing', + actions: [ + { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://doing' }, + { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://ready' } + ] + }); + todoFlow.addTransition({ + actionName: 'Finish', + fromState: 'doing', + toState: 'done', + actions: [ + { action: 'addLink', source: 'this', predicate: 'todo://state', target: 'todo://done' }, + { action: 'removeLink', source: 'this', predicate: 'todo://state', target: 'todo://doing' } + ] + }); + + // Register the flow + await perspective.addFlow('TODO', todoFlow); + + // Test sdnaFlows() returns the flow name + let flows = await perspective.sdnaFlows(); + expect(flows).to.include('TODO'); + + // Add an expression and test availableFlows() + await perspective.add(new Link({ source: 'ad4m://self', target: 'test-lang://1234' })); + let availableFlows = await perspective.availableFlows('test-lang://1234'); + expect(availableFlows.length).to.be.equal(1); + expect(availableFlows[0]).to.be.equal('TODO'); + + // Test startFlow() creates the right links + await perspective.startFlow('TODO', 'test-lang://1234'); + + let flowLinks = await ad4mClient.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ source: 'test-lang://1234', predicate: 'todo://state' }) + ); + expect(flowLinks.length).to.be.equal(1); + expect(flowLinks[0].data.target).to.be.equal('todo://ready'); + + // Test flowState() returns correct state + let todoState = await perspective.flowState('TODO', 'test-lang://1234'); + expect(todoState).to.be.equal(0); + + // Test expressionsInFlowState() finds expressions + let expressionsInTodo = await perspective.expressionsInFlowState('TODO', 0); + expect(expressionsInTodo.length).to.be.equal(1); + expect(expressionsInTodo[0]).to.be.equal('test-lang://1234'); + + // Test flowActions() returns available actions + let flowActions = await perspective.flowActions('TODO', 'test-lang://1234'); + expect(flowActions.length).to.be.equal(1); + expect(flowActions[0]).to.be.equal('Start'); + + // Test runFlowAction() transitions state: ready -> doing + await perspective.runFlowAction('TODO', 'test-lang://1234', 'Start'); + await sleep(100); + + todoState = await perspective.flowState('TODO', 'test-lang://1234'); + expect(todoState).to.be.equal(0.5); + + flowLinks = await ad4mClient.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ source: 'test-lang://1234', predicate: 'todo://state' }) + ); + expect(flowLinks.length).to.be.equal(1); + expect(flowLinks[0].data.target).to.be.equal('todo://doing'); + + expressionsInTodo = await perspective.expressionsInFlowState('TODO', 0.5); + expect(expressionsInTodo.length).to.be.equal(1); + expect(expressionsInTodo[0]).to.be.equal('test-lang://1234'); + + // Test transition: doing -> done + flowActions = await perspective.flowActions('TODO', 'test-lang://1234'); + expect(flowActions.length).to.be.equal(1); + expect(flowActions[0]).to.be.equal('Finish'); + + await perspective.runFlowAction('TODO', 'test-lang://1234', 'Finish'); + await sleep(100); + + todoState = await perspective.flowState('TODO', 'test-lang://1234'); + expect(todoState).to.be.equal(1); + + flowLinks = await ad4mClient.perspective.queryLinks( + perspective.uuid, + new LinkQuery({ source: 'test-lang://1234', predicate: 'todo://state' }) + ); + expect(flowLinks.length).to.be.equal(1); + expect(flowLinks[0].data.target).to.be.equal('todo://done'); + + expressionsInTodo = await perspective.expressionsInFlowState('TODO', 1); + expect(expressionsInTodo.length).to.be.equal(1); + expect(expressionsInTodo[0]).to.be.equal('test-lang://1234'); + }); + }) } }