Skip to content

Commit 02ea3a9

Browse files
authored
691 better way to deal with sub schema duplications in schema extraction (#978)
* make schema extraction avoid duplicate subschemas * fix type errors
1 parent c7395fb commit 02ea3a9

4 files changed

Lines changed: 169 additions & 23 deletions

File tree

meta_configurator/src/components/panels/gui-editor/properties/DateProperty.vue

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {ref, watch} from 'vue';
44
import DatePicker from 'primevue/datepicker';
55
import type {JsonSchemaWrapper} from '@/schema/jsonSchemaWrapper';
66
import type {PathElement} from '@/utility/path';
7-
import type {ValidationResult} from '@/schema/validationService';
7+
import type {ValidationResult} from '@/schema/validationUtils';
88
import {isReadOnly} from '@/components/panels/gui-editor/configTreeNodeReadingUtils';
99
1010
const props = defineProps<{
@@ -30,12 +30,13 @@ watch(
3030
}
3131
);
3232
33-
function updateValue(newDate: Date | undefined) {
33+
function updateValue(value: Date | (Date | null)[] | Date[] | null | undefined) {
34+
// DatePicker is used in single-date mode here, so we only handle a single Date (or empty).
35+
const newDate = value instanceof Date ? value : undefined;
3436
if (!newDate) {
3537
emit('update:propertyData', undefined);
3638
return;
3739
}
38-
// convert Date back to ISO date string (YYYY-MM-DD)
3940
const isoString = newDate.toISOString().split('T')[0];
4041
emit('update:propertyData', isoString);
4142
}

meta_configurator/src/data/managedSession.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {getDataForMode, getSchemaForMode} from '@/data/useDataLink';
66
import {SessionMode} from '@/store/sessionMode';
77
import type {SearchResult} from '@/utility/search';
88
import {EffectiveSchema} from '@/schema/effectiveSchemaCalculator';
9-
import type {ConfigDataTreeNode} from '@/components/panels/gui-editor/configDataTreeNode';
9+
import type {GuiEditorTreeNode} from '@/components/panels/gui-editor/configDataTreeNode';
1010

1111
export class ManagedSession {
1212
constructor(public mode: SessionMode) {}
@@ -81,7 +81,7 @@ export class ManagedSession {
8181
/**
8282
* Returns true if the node is selected, or if the node or any of its children matches a search result.
8383
*/
84-
public isNodeHighlighted(node: ConfigDataTreeNode) {
84+
public isNodeHighlighted(node: GuiEditorTreeNode) {
8585
const selectedPath = pathToString(this.currentSelectedElement.value);
8686
if (node.key && node.key === selectedPath) {
8787
return true;

meta_configurator/src/schema/__tests__/schemaManipulationUtils.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {shallowRef} from 'vue';
33
import {ManagedData} from '@/data/managedData';
44
import {SessionMode} from '@/store/sessionMode';
55
import {
6+
doesIdenticalSchemaDefinitionExist,
67
extractAllInlinedSchemaElements,
78
extractInlinedSchemaElement,
89
} from '@/schema/schemaManipulationUtils';
@@ -162,6 +163,129 @@ describe('schemaManipulationUtils', () => {
162163
});
163164
});
164165

166+
it('reuses an existing matching definition under a different name when duplicate reuse is enabled', () => {
167+
// existing def is named "vector" but has the same content as the inlined a2 sub-schema
168+
schemaData.setDataAt(['$defs', 'vector'], {
169+
type: 'object',
170+
properties: {
171+
a2a: {
172+
type: 'string',
173+
},
174+
},
175+
});
176+
177+
const extractedPath = extractInlinedSchemaElement(
178+
['properties', 'a', 'properties', 'a2'],
179+
schemaData,
180+
// candidate name is different from the existing one; the dedupe should still find vector by content
181+
'a2',
182+
true
183+
);
184+
185+
expect(extractedPath).toEqual(['$defs', 'vector']);
186+
expect(schemaData.dataAt(['$defs', 'a2'])).toBeUndefined();
187+
expect(schemaData.dataAt(['properties', 'a', 'properties', 'a2'])).toEqual({
188+
$ref: '#/$defs/vector',
189+
});
190+
expect(schemaData.dataAt(['properties', 'a', 'properties', 'a3'])).toEqual({
191+
type: 'object',
192+
$ref: '#/$defs/vector',
193+
});
194+
});
195+
196+
it('collapses identical sub-schemas into a single $defs entry during bulk extraction', () => {
197+
schemaData = new ManagedData(
198+
shallowRef({
199+
type: 'object',
200+
properties: {
201+
point1: {
202+
type: 'object',
203+
properties: {
204+
x: {type: 'number'},
205+
y: {type: 'number'},
206+
},
207+
},
208+
point2: {
209+
type: 'object',
210+
properties: {
211+
x: {type: 'number'},
212+
y: {type: 'number'},
213+
},
214+
},
215+
point3: {
216+
// key order differs from point1/point2; deep equality should still consider it equal
217+
type: 'object',
218+
properties: {
219+
y: {type: 'number'},
220+
x: {type: 'number'},
221+
},
222+
},
223+
},
224+
}),
225+
SessionMode.SchemaEditor
226+
);
227+
228+
const extractedCount = extractAllInlinedSchemaElements(schemaData, false, false);
229+
230+
// root is excluded by extractRootElement=false. The 3 inlined point objects should all
231+
// collapse into a single $defs entry — so only one definition is actually created.
232+
expect(Object.keys(schemaData.data.value.$defs)).toHaveLength(1);
233+
const defName = Object.keys(schemaData.data.value.$defs)[0]!;
234+
const expectedRef = {$ref: `#/$defs/${defName}`};
235+
expect(schemaData.data.value.properties.point1).toEqual(expectedRef);
236+
expect(schemaData.data.value.properties.point2).toEqual(expectedRef);
237+
expect(schemaData.data.value.properties.point3).toEqual(expectedRef);
238+
// each of the three properties was processed
239+
expect(extractedCount).toBe(3);
240+
});
241+
242+
describe('doesIdenticalSchemaDefinitionExist', () => {
243+
it('returns the path of an existing definition with deeply equal content', () => {
244+
schemaData.setDataAt(['$defs', 'foo'], {
245+
type: 'object',
246+
properties: {x: {type: 'number'}},
247+
});
248+
249+
const match = doesIdenticalSchemaDefinitionExist(schemaData, {
250+
// keys in different order, but structurally identical
251+
properties: {x: {type: 'number'}},
252+
type: 'object',
253+
});
254+
255+
expect(match).toEqual(['$defs', 'foo']);
256+
});
257+
258+
it('returns undefined when no equivalent definition exists', () => {
259+
schemaData.setDataAt(['$defs', 'foo'], {type: 'string'});
260+
261+
const match = doesIdenticalSchemaDefinitionExist(schemaData, {type: 'number'});
262+
263+
expect(match).toBeUndefined();
264+
});
265+
266+
it('returns undefined when there is no $defs section', () => {
267+
const match = doesIdenticalSchemaDefinitionExist(schemaData, {type: 'string'});
268+
expect(match).toBeUndefined();
269+
});
270+
271+
it('also searches the legacy "definitions" section', () => {
272+
schemaData.setDataAt(['definitions', 'legacyFoo'], {type: 'object'});
273+
274+
const match = doesIdenticalSchemaDefinitionExist(schemaData, {type: 'object'});
275+
276+
expect(match).toEqual(['definitions', 'legacyFoo']);
277+
});
278+
279+
it('prefers a $defs match over a definitions match when both exist', () => {
280+
schemaData.setDataAt(['definitions', 'legacyFoo'], {type: 'string'});
281+
schemaData.setDataAt(['$defs', 'modernFoo'], {type: 'string'});
282+
283+
const match = doesIdenticalSchemaDefinitionExist(schemaData, {type: 'string'});
284+
285+
expect(match).toEqual(['$defs', 'modernFoo']);
286+
});
287+
});
288+
165289
it('extracts inlined subschemas that are nested inside an existing $defs entry', () => {
166290
schemaData = new ManagedData(
167291
shallowRef({

meta_configurator/src/schema/schemaManipulationUtils.ts

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {constructSchemaGraph} from '@/schema/graph-representation/schemaGraphCon
77
import type {SchemaNodeData} from '@/schema/graph-representation/schemaGraphTypes';
88
import {updateReferences} from '@/utility/renameUtils';
99
import {stringToIdentifier} from '@/utility/stringToIdentifier';
10+
import _ from 'lodash';
1011

1112
export function extractAllInlinedSchemaElements(
1213
schemaData: ManagedData,
@@ -33,7 +34,7 @@ export function extractAllInlinedSchemaElements(
3334
node.title,
3435
node.fallbackDisplayName
3536
);
36-
extractInlinedSchemaElement(node.absolutePath, schemaData, newIdentifier);
37+
extractInlinedSchemaElement(node.absolutePath, schemaData, newIdentifier, true);
3738
nodesExtracted++;
3839
});
3940

@@ -57,23 +58,17 @@ export function extractInlinedSchemaElement(
5758
};
5859

5960
if (forgetIfDuplicateExists) {
60-
// if an existing definition exists with the same content, we can just reference that
61-
const existingElementDefPath = ['$defs', elementName];
62-
const existingElementDef = dataAt(existingElementDefPath, schemaData.data.value);
63-
if (existingElementDef) {
64-
if (JSON.stringify(existingElementDef) === JSON.stringify(dataAtPath)) {
65-
const referenceToNewElement = '#' + pathToJsonPointer(existingElementDefPath);
66-
schemaData.setDataAt(absoluteElementPath, {
67-
$ref: referenceToNewElement,
68-
});
69-
updateReferences(
70-
absoluteElementPath,
71-
existingElementDefPath,
72-
schemaData.data.value,
73-
updateDataFct
74-
);
75-
return existingElementDefPath;
76-
}
61+
// if an equivalent definition already exists anywhere in $defs (not just at the
62+
// candidate name), reference that one instead of creating a duplicate. This collapses
63+
// identical sub-schemas
64+
const existingDefPath = doesIdenticalSchemaDefinitionExist(schemaData, dataAtPath);
65+
if (existingDefPath) {
66+
const referenceToExistingElement = '#' + pathToJsonPointer(existingDefPath);
67+
schemaData.setDataAt(absoluteElementPath, {
68+
$ref: referenceToExistingElement,
69+
});
70+
updateReferences(absoluteElementPath, existingDefPath, schemaData.data.value, updateDataFct);
71+
return existingDefPath;
7772
}
7873
}
7974

@@ -87,6 +82,32 @@ export function extractInlinedSchemaElement(
8782
return newElementId;
8883
}
8984

85+
/**
86+
* Looks through every entry in the schema's top-level $defs and (legacy) definitions
87+
* sections and returns the path of the first one whose content is deeply equal to
88+
* `schemaToCheck`. Returns undefined if no such definition exists.
89+
*
90+
* Used to avoid creating duplicate definition entries when extracting sub-schemas: if an
91+
* equivalent definition is already there, the caller can reference it instead.
92+
*/
93+
export function doesIdenticalSchemaDefinitionExist(
94+
schemaData: ManagedData,
95+
schemaToCheck: any
96+
): Path | undefined {
97+
for (const defsKey of ['$defs', 'definitions']) {
98+
const defs = dataAt([defsKey], schemaData.data.value);
99+
if (!defs || typeof defs !== 'object') {
100+
continue;
101+
}
102+
for (const name of Object.keys(defs)) {
103+
if (_.isEqual(defs[name], schemaToCheck)) {
104+
return [defsKey, name];
105+
}
106+
}
107+
}
108+
return undefined;
109+
}
110+
90111
export function createIdentifierForExtractedElement(
91112
name: string | undefined,
92113
title: string | undefined,

0 commit comments

Comments
 (0)