Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .kiro/steering/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,10 @@ accidental mixing of similar types at compile time.
| `ConfigurationId` | `createNewConfigurationId()` | `@/core/ConfigurationProvider/types` |
| `RenderedVertexId` | `toRenderedVertexId()` | `@/core/StateProvider/renderedEntities` |
| `RenderedEdgeId` | `toRenderedEdgeId()` | `@/core/StateProvider/renderedEntities` |
| `IriNamespace` | `as IriNamespace` | `@/utils/rdf` |
| `IriLocalValue` | `as IriLocalValue` | `@/utils/rdf` |
| `RdfPrefix` | `as RdfPrefix` | `@/utils/rdf` |
| `NormalizedIriNamespace` | `as NormalizedIriNamespace` | `@/utils/rdf` |
| `IriNamespace` | `splitIri()` | `@/utils/rdf` |
| `IriLocalValue` | `splitIri()` | `@/utils/rdf` |
| `RdfPrefix` | `generatePrefix()` | `@/utils/rdf` |
| `NormalizedIriNamespace` | `normalizeNamespace()` | `@/utils/rdf` |

Always use the appropriate branded type instead of `string` when working with
these identifiers.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,6 @@ export type PrefixTypeConfig = {
* Mark as true after inferring from the schema.
*/
__inferred?: boolean;
/**
* Internal purpose only.
* Matches URIs
*/
__matches?: Set<string>;
};

/**
Expand Down
157 changes: 153 additions & 4 deletions packages/graph-explorer/src/core/StateProvider/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
mapEdgeToTypeConfig,
mapVertexToTypeConfigs,
maybeActiveSchemaAtom,
type SchemaStorageModel,
shouldUpdateSchemaFromEntities,
updateSchemaFromEntities,
updateSchemaPrefixes,
Expand Down Expand Up @@ -244,6 +245,24 @@ describe("schema", () => {
newNodes.flatMap(mapVertexToTypeConfigs).flatMap(n => n.attributes),
);
});

it("should generate prefixes from vertex IDs", () => {
const schema = createRandomSchema();
schema.vertices = [];
schema.edges = [];
schema.prefixes = [];

const vertex = createVertex({
...createRandomVertex(),
id: "http://data.nobelprize.org/resource/country/France",
types: ["http://data.nobelprize.org/class/Country"],
});

const result = updateSchemaFromEntities({ vertices: [vertex] }, schema);

const prefixes = result.prefixes?.map(p => p.prefix);
expect(prefixes).toContain("country");
});
});

describe("updateSchemaPrefixes", () => {
Expand Down Expand Up @@ -275,16 +294,40 @@ describe("schema", () => {
expect(result.prefixes).toBeDefined();
expect(result.prefixes).toEqual([
{
prefix: "ver" as RdfPrefix,
prefix: "vertex" as RdfPrefix,
uri: "http://abcdefg.com/vertex#" as IriNamespace,
__inferred: true,
__matches: new Set(schema.vertices.map(v => v.type)),
},
{
prefix: "edg" as RdfPrefix,
prefix: "edge" as RdfPrefix,
uri: "http://abcdefg.com/edge#" as IriNamespace,
__inferred: true,
__matches: new Set(schema.edges.map(e => e.type)),
},
] satisfies PrefixTypeConfig[]);
});

it("should append new prefixes to existing ones", () => {
const schema = createRandomSchema();
const existingPrefix: PrefixTypeConfig = {
prefix: "custom" as RdfPrefix,
uri: "http://custom.example.com/" as IriNamespace,
};
schema.prefixes = [existingPrefix];
schema.vertices.forEach(v => {
v.type = createVertexType(
"http://abcdefg.com/vertex#" + encodeURIComponent(v.type),
);
});
schema.edges = [];

const result = updateSchemaPrefixes(schema);

expect(result.prefixes).toStrictEqual([
existingPrefix,
{
prefix: "vertex" as RdfPrefix,
uri: "http://abcdefg.com/vertex#" as IriNamespace,
__inferred: true,
},
] satisfies PrefixTypeConfig[]);
});
Expand Down Expand Up @@ -686,3 +729,109 @@ describe("useActiveSchema", () => {
expect(result.current).toStrictEqual(schema);
});
});

/**
* BACKWARD COMPATIBILITY — PERSISTED DATA
*
* SchemaStorageModel (including its PrefixTypeConfig[] in `prefixes`) is
* persisted to IndexedDB via localforage. Older versions stored a `__matches`
* property (Set<string>) on inferred prefixes. That property has been removed
* from PrefixTypeConfig, but previously persisted data may still contain it.
* These tests verify that schema operations continue to work correctly when
* the schema contains prefixes in the old shape.
*
* DO NOT delete or weaken these tests without confirming that all persisted
* data has been migrated or that the old shape is no longer in the wild.
*/
describe("backward compatibility: legacy __matches on prefixes", () => {
it("updateSchemaPrefixes should preserve legacy prefixes and append new ones", () => {
// Simulates a schema loaded from IndexedDB that was persisted before
// __matches was removed from PrefixTypeConfig.
const legacyPrefix = {
prefix: "soccer" as RdfPrefix,
uri: "http://www.example.com/soccer/ontology/" as IriNamespace,
__inferred: true,
__matches: new Set(["http://www.example.com/soccer/ontology/League"]),
} as PrefixTypeConfig;

const schema = createRandomSchema();
schema.prefixes = [legacyPrefix];
schema.vertices.forEach(v => {
v.type = createVertexType(
"http://newdomain.com/vertex#" + encodeURIComponent(v.type),
);
});
schema.edges = [];

const result = updateSchemaPrefixes(schema);

// Legacy prefix should be preserved as-is at the start of the array
expect(result.prefixes?.[0]).toBe(legacyPrefix);
// New prefix should be appended
expect(result.prefixes).toHaveLength(2);
expect(result.prefixes?.[1]).toStrictEqual({
prefix: "vertex" as RdfPrefix,
uri: "http://newdomain.com/vertex#" as IriNamespace,
__inferred: true,
});
});

it("updateSchemaPrefixes should not regenerate prefixes already covered by legacy entries", () => {
// The legacy prefix covers the same namespace as the vertex types,
// so no new prefixes should be generated.
const legacyPrefix = {
prefix: "soccer" as RdfPrefix,
uri: "http://www.example.com/soccer/ontology/" as IriNamespace,
__inferred: true,
__matches: new Set(["http://www.example.com/soccer/ontology/League"]),
} as PrefixTypeConfig;

const schema = createRandomSchema();
schema.prefixes = [legacyPrefix];
schema.vertices = [
{
type: createVertexType("http://www.example.com/soccer/ontology/Player"),
attributes: [],
},
];
schema.edges = [];

const result = updateSchemaPrefixes(schema);

// No change — the legacy prefix already covers this namespace
expect(result).toBe(schema);
});

it("updateSchemaFromEntities should work with schema containing legacy prefixes", () => {
const legacyPrefix = {
prefix: "old" as RdfPrefix,
uri: "http://old.example.com/" as IriNamespace,
__inferred: true,
__matches: new Set(["http://old.example.com/Thing"]),
} as PrefixTypeConfig;

const schema: SchemaStorageModel = {
vertices: [],
edges: [],
prefixes: [legacyPrefix],
};

const vertex = createVertex({
id: "http://new.example.com/vertex#1",
types: ["http://new.example.com/vertex#Person"],
attributes: {},
});

const result = updateSchemaFromEntities({ vertices: [vertex] }, schema);

// Legacy prefix should be preserved
expect(result.prefixes?.[0]).toBe(legacyPrefix);
// New prefix should be appended for the new namespace
expect(result.prefixes).toHaveLength(2);
expect(result.prefixes?.[1]).toStrictEqual({
prefix: "vertex" as RdfPrefix,
uri: "http://new.example.com/vertex#" as IriNamespace,
__inferred: true,
});
});
});
41 changes: 25 additions & 16 deletions packages/graph-explorer/src/core/StateProvider/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
type VertexType,
} from "@/core";
import { logger } from "@/utils";
import { generatePrefixes } from "@/utils/rdf";
import { generatePrefixes, PrefixLookup } from "@/utils/rdf";

/**
* Persisted schema state for a database connection.
Expand Down Expand Up @@ -113,15 +113,14 @@ export function useMaybeActiveSchema(): SchemaStorageModel | undefined {
return useDeferredValue(useAtomValue(maybeActiveSchemaAtom));
}

/** Gets the stored prefixes from the active schema. */
export function usePrefixes(): PrefixTypeConfig[] {
const schema = useActiveSchema();
return schema.prefixes ?? [];
/** Gets the stored prefixes from the active schema as a lookup object. */
export function usePrefixes() {
return useAtomValue(prefixesAtom);
}

export const prefixesAtom = atom(get => {
const schema = get(activeSchemaAtom);
return schema.prefixes ?? [];
return PrefixLookup.fromArray(schema.prefixes ?? []);
});

function createVertexSchema(vtConfig: VertexTypeConfig) {
Expand Down Expand Up @@ -304,7 +303,7 @@ export function updateSchemaFromEntities(
} satisfies SchemaStorageModel;

// Update the generated prefixes in the schema
newSchema = updateSchemaPrefixes(newSchema);
newSchema = updateSchemaPrefixes(newSchema, entities);

logger.debug("Updated schema:", { newSchema, prevSchema: schema });
return newSchema;
Expand Down Expand Up @@ -417,31 +416,34 @@ function detectDataType(value: ScalarValue) {
/** Generate RDF prefixes for all the resource URIs in the schema. */
export function updateSchemaPrefixes(
schema: SchemaStorageModel,
entities?: Partial<Entities>,
): SchemaStorageModel {
const existingPrefixes = schema.prefixes ?? [];
const existingPrefixes = PrefixLookup.fromArray(schema.prefixes ?? []);

// Get all the resource URIs from the vertex and edge type configs
const resourceUris = getResourceUris(schema);
const resourceUris = getResourceUris(schema, entities);

if (resourceUris.size === 0) {
return schema;
}

const genPrefixes = generatePrefixes(resourceUris, existingPrefixes);
if (!genPrefixes?.length) {
const newPrefixes = generatePrefixes(resourceUris, existingPrefixes);
if (newPrefixes.length === 0) {
return schema;
}

logger.debug("Updating schema with prefixes:", genPrefixes);
logger.debug("Updating schema with prefixes:", newPrefixes);

return {
...schema,
prefixes: genPrefixes,
prefixes: [...(schema.prefixes ?? []), ...newPrefixes],
};
}

/** A performant way to construct the set of resource URIs from the schema. */
function getResourceUris(schema: SchemaStorageModel) {
/** Collects resource URIs from schema type configs and entity IDs. */
function getResourceUris(
schema: SchemaStorageModel,
entities?: Partial<Entities>,
) {
const result = new Set<string>();

schema.vertices.forEach(v => {
Expand All @@ -454,6 +456,13 @@ function getResourceUris(schema: SchemaStorageModel) {
result.add(e.type);
});

for (const v of entities?.vertices ?? []) {
result.add(String(v.id));
}
for (const e of entities?.edges ?? []) {
result.add(String(e.id));
}

return result;
}

Expand Down
Loading