F: Implementing references pt2
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-08-04 22:42:02 +03:00
parent 13e56f51ea
commit 266fdf0c30
9 changed files with 117 additions and 137 deletions

View File

@ -39,34 +39,29 @@ class OperationSchemaCached:
def delete_reference(self, target: int, keep_connections: bool = False, keep_constituents: bool = False): def delete_reference(self, target: int, keep_connections: bool = False, keep_constituents: bool = False):
''' Delete Reference Operation. ''' ''' Delete Reference Operation. '''
if not keep_connections:
self.delete_operation(target, keep_constituents)
return
self.cache.ensure_loaded_subs() self.cache.ensure_loaded_subs()
operation = self.cache.operation_by_id[target] operation = self.cache.operation_by_id[target]
if keep_connections: reference_target = self.cache.reference_target.get(target)
referred_operations = operation.getQ_reference_target() if reference_target:
if len(referred_operations) == 1: for arg in operation.getQ_as_argument():
referred_operation = referred_operations[0] arg.argument_id = reference_target
for arg in operation.getQ_as_argument(): arg.save()
arg.pk = None
arg.argument = referred_operation
arg.save()
else:
pass
# if target.result_id is not None:
# self.before_delete_cst(schema, schema.cache.constituents) # TODO: use operation instead of schema
self.cache.remove_operation(target) self.cache.remove_operation(target)
operation.delete() operation.delete()
def delete_operation(self, target: int, keep_constituents: bool = False): def delete_operation(self, target: int, keep_constituents: bool = False):
''' Delete Operation. ''' ''' Delete Operation. '''
self.cache.ensure_loaded_subs() self.cache.ensure_loaded_subs()
operation = self.cache.operation_by_id[target] operation = self.cache.operation_by_id[target]
schema = self.cache.get_schema(operation)
children = self.cache.graph.outputs[target] children = self.cache.graph.outputs[target]
if schema is not None and len(children) > 0: if operation.result is not None and len(children) > 0:
ids = [cst.pk for cst in schema.cache.constituents] ids = list(Constituenta.objects.filter(schema=operation.result).values_list('pk', flat=True))
if not keep_constituents: if not keep_constituents:
self.before_delete_cst(schema.model.pk, ids) self._cascade_delete_inherited(operation.pk, ids)
else: else:
inheritance_to_delete: list[Inheritance] = [] inheritance_to_delete: list[Inheritance] = []
for child_id in children: for child_id in children:
@ -837,6 +832,8 @@ class OssCache:
del self._schema_by_id[target.result_id] del self._schema_by_id[target.result_id]
self.operations.remove(self.operation_by_id[operation]) self.operations.remove(self.operation_by_id[operation])
del self.operation_by_id[operation] del self.operation_by_id[operation]
if operation in self.reference_target:
del self.reference_target[operation]
if self.is_loaded_subs: if self.is_loaded_subs:
del self.substitutions[operation] del self.substitutions[operation]
del self.inheritance[operation] del self.inheritance[operation]

View File

@ -80,23 +80,42 @@ class ReferencePropagationTestCase(EndpointTester):
self.owned.set_arguments(self.operation5.pk, [self.operation4, self.operation3]) self.owned.set_arguments(self.operation5.pk, [self.operation4, self.operation3])
self.owned.set_substitutions(self.operation5.pk, [{ self.owned.set_substitutions(self.operation5.pk, [{
'original': self.ks4X1, 'original': self.ks4X1,
'substitution': self.ks1X1 'substitution': self.ks1X2
}]) }])
self.owned.execute_operation(self.operation5) self.owned.execute_operation(self.operation5)
self.operation5.refresh_from_db() self.operation5.refresh_from_db()
self.ks5 = RSForm(self.operation5.result) self.ks5 = RSForm(self.operation5.result)
self.ks5D4 = self.ks5.insert_last( self.ks5D4 = self.ks5.insert_last(
alias='D4', alias='D4',
definition_formal=r'X1 X2 X3 S1 D1 D2 D3', definition_formal=r'X1 X2 X3 X4 S1 D1 D2 D3',
convention='KS5D4' convention='KS5D4'
) )
self.operation6 = self.owned.create_operation(
alias='6',
operation_type=OperationType.SYNTHESIS
)
self.owned.set_arguments(self.operation6.pk, [self.operation2, self.operation3])
self.owned.set_substitutions(self.operation6.pk, [{
'original': self.ks2X1,
'substitution': self.ks1X1
}])
self.owned.execute_operation(self.operation6)
self.operation6.refresh_from_db()
self.ks6 = RSForm(self.operation6.result)
self.ks6D2 = self.ks6.insert_last(
alias='D2',
definition_formal=r'X1 X2 X3 S1 D1',
convention='KS6D2'
)
self.layout_data = [ self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, {'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40} {'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation6.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
] ]
layout = OperationSchema.layoutQ(self.owned_id) layout = OperationSchema.layoutQ(self.owned_id)
layout.data = self.layout_data layout.data = self.layout_data
@ -106,94 +125,22 @@ class ReferencePropagationTestCase(EndpointTester):
def test_reference_creation(self): def test_reference_creation(self):
''' Test reference creation. ''' ''' Test reference creation. '''
self.assertEqual(self.operation1.result, self.operation3.result) self.assertEqual(self.operation1.result, self.operation3.result)
self.assertEqual(self.ks1.constituentsQ().count(), 3)
self.assertEqual(self.ks2.constituentsQ().count(), 3)
self.assertEqual(self.ks4.constituentsQ().count(), 6)
self.assertEqual(self.ks5.constituentsQ().count(), 9)
self.assertEqual(self.ks6.constituentsQ().count(), 6)
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_target_propagation(self):
''' Test propagation when deleting a target operation. '''
data = {
'layout': self.layout_data,
'target': self.operation1.pk
}
self.executeOK(data=data, item=self.owned_id)
self.assertEqual(self.ks6.constituentsQ().count(), 4)
# self.assertEqual(self.ks5.constituentsQ().count(), 5)
# @decl_endpoint('/api/oss/{item}/delete-reference', method='patch') # TODO: add more tests
# def test_delete_reference_propagation(self):
# ''' Test propagation when deleting a reference operation. '''
# schema_cached = OperationSchemaCached(self.schema2)
# # Ensure reference exists
# self.assertIn(self.reference.pk, schema_cached.cache.operation_by_id)
# # Delete the reference
# schema_cached.delete_reference(self.reference.pk)
# # Reference should be deleted
# with self.assertRaises(Reference.DoesNotExist):
# Reference.objects.get(pk=self.reference.pk)
# # Operation2 should still exist
# self.assertTrue(Operation.objects.filter(pk=self.op2.pk).exists())
# @decl_endpoint('/api/oss/{item}/set-arguments', method='patch')
# def test_set_arguments_propagation(self):
# ''' Test propagation when setting arguments for a reference. '''
# schema_cached = OperationSchemaCached(self.schema2)
# # Add op1 as argument to op2
# schema_cached.set_arguments(self.op2.pk, [self.op1])
# op2 = Operation.objects.get(pk=self.op2.pk)
# args = list(op2.getQ_arguments())
# self.assertEqual(len(args), 1)
# self.assertEqual(args[0].argument, self.op1)
# @decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
# def test_delete_operation_with_reference(self):
# ''' Test propagation when deleting an operation that is referenced. '''
# schema_cached = OperationSchemaCached(self.schema1)
# # op1 is referenced by reference
# self.assertEqual(self.reference.getQ_reference_target(), self.op1)
# # Delete op1
# schema_cached.delete_operation(self.op1.pk)
# # op1 should be deleted
# with self.assertRaises(Operation.DoesNotExist):
# Operation.objects.get(pk=self.op1.pk)
# # Reference should be deleted as well
# self.assertFalse(Reference.objects.filter(pk=self.reference.pk).exists())
# @decl_endpoint('/api/oss/{item}/add-constituent', method='patch')
# def test_add_constituent_propagation(self):
# ''' Test propagation when adding a constituent to a referenced schema. '''
# # Add a new constituent to schema1 (referenced by op1, which is referenced by reference)
# new_cst = Constituenta.objects.create(
# schema=self.schema1, alias='cst_new', title='New Constituenta', cst_type=CstType.ATTRIBUTE
# )
# # Simulate propagation: after adding, the reference should still be valid and schema1 should have the new cst
# self.assertTrue(Constituenta.objects.filter(pk=new_cst.pk, schema=self.schema1).exists())
# # The reference's target operation's result should include the new constituent
# op1 = Operation.objects.get(pk=self.op1.pk)
# self.assertEqual(op1.result, self.schema1)
# self.assertIn(new_cst, Constituenta.objects.filter(schema=op1.result))
# @decl_endpoint('/api/oss/{item}/remove-constituent', method='patch')
# def test_remove_constituent_propagation(self):
# ''' Test propagation when removing a constituent from a referenced schema. '''
# # Remove cst2 from schema1
# self.cst2.delete()
# # The reference's target operation's result should not include cst2
# op1 = Operation.objects.get(pk=self.op1.pk)
# self.assertEqual(op1.result, self.schema1)
# self.assertNotIn(self.cst2, Constituenta.objects.filter(schema=op1.result))
# # Reference should still be valid
# self.assertEqual(self.reference.getQ_reference_target(), self.op1)
# @decl_endpoint('/api/oss/{item}/add-constituent-to-referenced-schema', method='patch')
# def test_propagation_to_multiple_references(self):
# ''' Test propagation when a schema is referenced by multiple references and constituents are added. '''
# # Create another reference to op1
# reference2 = Reference.objects.create(
# alias='ref2', title='Reference 2', type=OperationType.REFERENCE, result=self.schema2
# )
# reference2.setQ_reference_target(self.op1)
# reference2.save()
# # Add a new constituent to schema1
# new_cst = Constituenta.objects.create(
# schema=self.schema1, alias='cst_multi', title='Multi Constituenta', cst_type=CstType.ATTRIBUTE
# )
# # Both references should still be valid and op1's result should include the new constituent
# op1 = Operation.objects.get(pk=self.op1.pk)
# self.assertIn(new_cst, Constituenta.objects.filter(schema=op1.result))
# self.assertEqual(self.reference.getQ_reference_target(), self.op1)
# self.assertEqual(reference2.getQ_reference_target(), self.op1)

View File

@ -27,6 +27,7 @@ export class OssLoader {
private itemByNodeID = new Map<string, IOssItem>(); private itemByNodeID = new Map<string, IOssItem>();
private blockByID = new Map<number, IBlock>(); private blockByID = new Map<number, IBlock>();
private schemaIDs: number[] = []; private schemaIDs: number[] = [];
private extendedGraph = new Graph();
constructor(input: RO<IOperationSchemaDTO>) { constructor(input: RO<IOperationSchemaDTO>) {
this.oss = structuredClone(input) as unknown as IOperationSchema; this.oss = structuredClone(input) as unknown as IOperationSchema;
@ -47,6 +48,7 @@ export class OssLoader {
result.hierarchy = this.hierarchy; result.hierarchy = this.hierarchy;
result.schemas = this.schemaIDs; result.schemas = this.schemaIDs;
result.stats = this.calculateStats(); result.stats = this.calculateStats();
result.extendedGraph = this.extendedGraph;
return result; return result;
} }
@ -57,6 +59,7 @@ export class OssLoader {
this.itemByNodeID.set(operation.nodeID, operation); this.itemByNodeID.set(operation.nodeID, operation);
this.operationByID.set(operation.id, operation); this.operationByID.set(operation.id, operation);
this.graph.addNode(operation.id); this.graph.addNode(operation.id);
this.extendedGraph.addNode(operation.id);
this.hierarchy.addNode(operation.nodeID); this.hierarchy.addNode(operation.nodeID);
if (operation.parent) { if (operation.parent) {
this.hierarchy.addEdge(constructNodeID(NodeType.BLOCK, operation.parent), operation.nodeID); this.hierarchy.addEdge(constructNodeID(NodeType.BLOCK, operation.parent), operation.nodeID);
@ -75,7 +78,13 @@ export class OssLoader {
} }
private createGraph() { private createGraph() {
this.oss.arguments.forEach(argument => this.graph.addEdge(argument.argument, argument.operation)); this.oss.arguments.forEach(argument => {
this.graph.addEdge(argument.argument, argument.operation);
this.extendedGraph.addEdge(argument.argument, argument.operation);
});
this.oss.references.forEach(reference => {
this.extendedGraph.addEdge(reference.target, reference.reference);
});
} }
private extractSchemas() { private extractSchemas() {
@ -102,7 +111,7 @@ export class OssLoader {
break; break;
case OperationType.REFERENCE: case OperationType.REFERENCE:
const ref = this.oss.references.find(item => item.reference === operationID); const ref = this.oss.references.find(item => item.reference === operationID);
const target = !!ref ? this.oss.operationByID.get(ref.target) : null; const target = !!ref ? this.operationByID.get(ref.target) : null;
if (!target || !ref) { if (!target || !ref) {
throw new Error(`Reference ${operationID} not found`); throw new Error(`Reference ${operationID} not found`);
} }
@ -128,13 +137,13 @@ export class OssLoader {
} }
private inferConsolidation(operationID: number): boolean { private inferConsolidation(operationID: number): boolean {
const inputs = this.graph.expandInputs([operationID]); const inputs = this.extendedGraph.expandInputs([operationID]);
if (inputs.length === 0) { if (inputs.length === 0) {
return false; return false;
} }
const ancestors = [...inputs]; const ancestors = [...inputs];
inputs.forEach(input => { inputs.forEach(input => {
ancestors.push(...this.graph.expandAllInputs([input])); ancestors.push(...this.extendedGraph.expandAllInputs([input]));
}); });
const unique = new Set(ancestors); const unique = new Set(ancestors);
return unique.size < ancestors.length; return unique.size < ancestors.length;

View File

@ -18,6 +18,9 @@ export function TabArguments() {
} = useFormContext<ICreateSynthesisDTO>(); } = useFormContext<ICreateSynthesisDTO>();
const inputs = useWatch({ control, name: 'arguments' }); const inputs = useWatch({ control, name: 'arguments' });
const references = manager.oss.references.filter(item => inputs.includes(item.target)).map(item => item.reference);
const filtered = manager.oss.operations.filter(item => !references.includes(item.id));
return ( return (
<div className='cc-fade-in cc-column'> <div className='cc-fade-in cc-column'>
<TextInput <TextInput
@ -64,7 +67,7 @@ export function TabArguments() {
name='arguments' name='arguments'
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<PickMultiOperation items={manager.oss.operations} value={field.value} onChange={field.onChange} rows={6} /> <PickMultiOperation items={filtered} value={field.value} onChange={field.onChange} rows={6} />
)} )}
/> />
</div> </div>

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
@ -32,6 +32,7 @@ export function DlgDeleteReference() {
keep_connections: false keep_connections: false
} }
}); });
const keep_connections = useWatch({ control, name: 'keep_connections' });
function onSubmit(data: IDeleteReferenceDTO) { function onSubmit(data: IDeleteReferenceDTO) {
return deleteReference({ itemID: oss.id, data: data }); return deleteReference({ itemID: oss.id, data: data });
@ -47,19 +48,6 @@ export function DlgDeleteReference() {
helpTopic={HelpTopic.CC_PROPAGATION} helpTopic={HelpTopic.CC_PROPAGATION}
> >
<TextInput disabled dense noBorder id='operation_alias' label='Операция' value={target.alias} /> <TextInput disabled dense noBorder id='operation_alias' label='Операция' value={target.alias} />
<Controller
control={control}
name='keep_constituents'
render={({ field }) => (
<Checkbox
label='Сохранить наследованные конституенты'
titleHtml='Наследованные конституенты <br/>превратятся в дописанные'
value={field.value}
onChange={field.onChange}
disabled={target.result === null}
/>
)}
/>
<Controller <Controller
control={control} control={control}
name='keep_connections' name='keep_connections'
@ -73,6 +61,19 @@ export function DlgDeleteReference() {
/> />
)} )}
/> />
<Controller
control={control}
name='keep_constituents'
render={({ field }) => (
<Checkbox
label='Сохранить наследованные конституенты'
titleHtml='Наследованные конституенты <br/>превратятся в дописанные'
value={field.value}
onChange={field.onChange}
disabled={target.result === null || keep_connections}
/>
)}
/>
</ModalForm> </ModalForm>
); );
} }

View File

@ -1,5 +1,5 @@
'use client'; 'use client';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { Label } from '@/components/input'; import { Label } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
@ -12,7 +12,12 @@ import { type DlgEditOperationProps } from './dlg-edit-operation';
export function TabArguments() { export function TabArguments() {
const { control, setValue } = useFormContext<IUpdateOperationDTO>(); const { control, setValue } = useFormContext<IUpdateOperationDTO>();
const { manager, target } = useDialogsStore(state => state.props as DlgEditOperationProps); const { manager, target } = useDialogsStore(state => state.props as DlgEditOperationProps);
const potentialCycle = [target.id, ...manager.oss.graph.expandAllOutputs([target.id])]; const args = useWatch({ control, name: 'arguments' });
const references = manager.oss.references
.filter(item => args.includes(item.target) || item.target === target.id)
.map(item => item.reference);
const potentialCycle = [target.id, ...references, ...manager.oss.graph.expandAllOutputs([target.id])];
const filtered = manager.oss.operations.filter(item => !potentialCycle.includes(item.id)); const filtered = manager.oss.operations.filter(item => !potentialCycle.includes(item.id));
function handleChangeArguments(prev: number[], newValue: number[]) { function handleChangeArguments(prev: number[], newValue: number[]) {

View File

@ -85,6 +85,7 @@ export interface IOperationSchema extends Omit<IOperationSchemaDTO, 'operations'
blocks: IBlock[]; blocks: IBlock[];
graph: Graph; graph: Graph;
extendedGraph: Graph;
hierarchy: Graph<string>; hierarchy: Graph<string>;
schemas: number[]; schemas: number[];
stats: IOperationSchemaStats; stats: IOperationSchemaStats;

View File

@ -206,15 +206,32 @@ export function MenuOperation({ operation, onHide }: MenuOperationProps) {
}).then(response => setTimeout(() => setSelected([`o${response.new_operation}`]), PARAMETER.refreshTimeout)); }).then(response => setTimeout(() => setSelected([`o${response.new_operation}`]), PARAMETER.refreshTimeout));
} }
function handleSelectTarget() {
onHide();
if (operation.operation_type !== OperationType.REFERENCE) {
return;
}
setSelected([`o${operation.target}`]);
}
return ( return (
<> <>
<DropdownButton {operation.operation_type !== OperationType.REFERENCE ? (
text='Редактировать' <DropdownButton
title='Редактировать операцию' text='Редактировать'
icon={<IconEdit2 size='1rem' className='icon-primary' />} title='Редактировать операцию'
onClick={handleEditOperation} icon={<IconEdit2 size='1rem' className='icon-primary' />}
disabled={!isMutable || isProcessing} onClick={handleEditOperation}
/> disabled={!isMutable || isProcessing}
/>
) : (
<DropdownButton
text='Оригинал'
title='Выделить оригинал'
icon={<IconReference size='1rem' className='icon-primary' />}
onClick={handleSelectTarget}
/>
)}
{operation.result ? ( {operation.result ? (
<DropdownButton <DropdownButton

View File

@ -109,7 +109,7 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
} }
function canDeleteOperation(target: IOperation) { function canDeleteOperation(target: IOperation) {
if (target.operation_type === OperationType.INPUT) { if (target.operation_type === OperationType.INPUT || target.operation_type === OperationType.REFERENCE) {
return true; return true;
} }
return schema.graph.expandOutputs([target.id]).length === 0; return schema.graph.expandOutputs([target.id]).length === 0;