Skip to content

Commit 3d2a606

Browse files
committed
fix: preserve CLI edit geometry
1 parent f9de2f5 commit 3d2a606

5 files changed

Lines changed: 152 additions & 34 deletions

File tree

‎skills/sql2er/references/commands.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ node $AGENT labels mode comment --state er.json
132132
- `nudge <id> <dx> <dy>`: shift a node by delta.
133133
- `swap <idA> <idB>`: exchange two entity positions.
134134

135-
Use exact ids from `describe`; run `describe --full` when targeting attributes. Each command runs one `arrange` pass afterward. Use `--raw` to skip settling and automatic avoidance. Coordinates are unbounded; exports fit the view to the graph.
135+
Use exact ids from `describe`; run `describe --full` when targeting attributes. Entity edits keep owned attributes attached and re-anchor related relationship diamonds. Automatic avoidance runs by default; use `--raw` to skip it. Coordinates are unbounded; exports fit the view to the graph.
136136

137137
## rotate `<degrees>`
138138

‎skills/sql2er/scripts/engine/cli.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,10 @@ Usage: node sql2er-agent.mjs <command> [args] [--flags] (state in ./sql2er-sta
180180
layout <optimal|arrange> Re-run a layout. optimal = planar skeleton seed + constrained stress
181181
(rooms for attribute rings; the recommended default);
182182
arrange = settle current positions.
183-
move <id> <x> <y> Place a node. Entity attributes and diamonds follow. Then settles
184-
with one arrange pass unless --raw.
185-
nudge <id> <dx> <dy> Shift a node by a delta. --raw to skip the settle pass.
186-
swap <idA> <idB> Exchange two entities' positions. --raw to skip settle.
183+
move <id> <x> <y> Place a node. Entity attributes and diamonds follow. Then auto-avoids
184+
unless --raw.
185+
nudge <id> <dx> <dy> Shift a node by a delta. --raw to skip automatic avoidance.
186+
swap <idA> <idB> Exchange two entities' positions. --raw to skip automatic avoidance.
187187
rotate <degrees> Rotate the whole diagram about its centre (shapes stay upright).
188188
attrs <auto|compact|moderate> Re-place attribute ellipses. compact = tightest
189189
non-overlapping pack; moderate = uniform even ring. Persists.

‎skills/sql2er/scripts/engine/ops.ts‎

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
* mutates it via the REAL app layout/styling functions, and returns the new model.
44
*
55
* Scope is deliberately agent-shaped: no scroll-zoom, no history, no continuous
6-
* force loop (an agent adjusts once, not by dragging). The "settle after an edit"
7-
* step is a single arrangeLayout pass — arrangeLayout refines the *current* coords
8-
* with deadband springs, so it preserves the agent's coarse intent while fixing
9-
* attributes / diamonds / overlaps.
6+
* force loop (an agent adjusts once, not by dragging). Direct edits preserve the
7+
* user's coarse intent: entity-owned attributes translate with the entity, related
8+
* diamonds are re-anchored between entities, and the optional post step is only
9+
* automatic overlap avoidance.
1010
*/
1111
import { parseSQLTables } from "@app/parser/sql";
1212
import { parseDBML } from "@app/parser/dbml";
@@ -812,14 +812,6 @@ export function setAttrMode(state: State, mode: AttrMode): State {
812812
return next;
813813
}
814814

815-
function settle(state: State) {
816-
state.settings = normalizeSettings(state.settings);
817-
const graph = styleAndSize(state.nodes, state.edges, state.settings);
818-
arrangeLayout(graph);
819-
applyAttrMode(state);
820-
applyAutoAvoid(state);
821-
}
822-
823815
export function setAutoAvoid(state: State, enabled: boolean): State {
824816
state.settings = normalizeSettings({ ...state.settings, autoAvoid: enabled });
825817
if (enabled) applyAutoAvoid(state);
@@ -833,7 +825,7 @@ export function move(state: State, arg: string, x: number, y: number, raw: boole
833825
const dy = y - (typeof node.y === "number" ? node.y : 0);
834826
translateCluster(state, node, dx, dy);
835827
if (node.nodeType === "entity") syncMovedEntities(state, [node.id], startPositions);
836-
if (!raw) settle(state);
828+
if (!raw) applyAutoAvoid(state);
837829
return { state: { ...state }, resolved: [{ id: node.id, label: String(node.label) }] };
838830
}
839831

@@ -842,7 +834,7 @@ export function nudge(state: State, arg: string, dx: number, dy: number, raw: bo
842834
const startPositions = captureNodePositions(state.nodes);
843835
translateCluster(state, node, dx, dy);
844836
if (node.nodeType === "entity") syncMovedEntities(state, [node.id], startPositions);
845-
if (!raw) settle(state);
837+
if (!raw) applyAutoAvoid(state);
846838
return { state: { ...state }, resolved: [{ id: node.id, label: String(node.label) }] };
847839
}
848840

@@ -866,7 +858,7 @@ export function swap(state: State, argA: string, argB: string, raw: boolean): Ed
866858
translateCluster(state, a, bx - ax, by - ay);
867859
translateCluster(state, b, ax - bx, ay - by);
868860
syncMovedEntities(state, [a.id, b.id], startPositions);
869-
if (!raw) settle(state);
861+
if (!raw) applyAutoAvoid(state);
870862
return {
871863
state: { ...state },
872864
resolved: [

‎skills/sql2er/scripts/sql2er-agent.mjs‎

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5994,13 +5994,6 @@ function setAttrMode(state, mode) {
59945994
applyAutoAvoid(next);
59955995
return next;
59965996
}
5997-
function settle(state) {
5998-
state.settings = normalizeSettings(state.settings);
5999-
const graph = styleAndSize(state.nodes, state.edges, state.settings);
6000-
arrangeLayout(graph);
6001-
applyAttrMode(state);
6002-
applyAutoAvoid(state);
6003-
}
60045997
function setAutoAvoid(state, enabled) {
60055998
state.settings = normalizeSettings({ ...state.settings, autoAvoid: enabled });
60065999
if (enabled) applyAutoAvoid(state);
@@ -6013,15 +6006,15 @@ function move(state, arg, x, y, raw) {
60136006
const dy = y - (typeof node.y === "number" ? node.y : 0);
60146007
translateCluster(state, node, dx, dy);
60156008
if (node.nodeType === "entity") syncMovedEntities(state, [node.id], startPositions);
6016-
if (!raw) settle(state);
6009+
if (!raw) applyAutoAvoid(state);
60176010
return { state: { ...state }, resolved: [{ id: node.id, label: String(node.label) }] };
60186011
}
60196012
function nudge(state, arg, dx, dy, raw) {
60206013
const node = resolveNodeById(state, arg);
60216014
const startPositions = captureNodePositions(state.nodes);
60226015
translateCluster(state, node, dx, dy);
60236016
if (node.nodeType === "entity") syncMovedEntities(state, [node.id], startPositions);
6024-
if (!raw) settle(state);
6017+
if (!raw) applyAutoAvoid(state);
60256018
return { state: { ...state }, resolved: [{ id: node.id, label: String(node.label) }] };
60266019
}
60276020
function swap(state, argA, argB, raw) {
@@ -6044,7 +6037,7 @@ function swap(state, argA, argB, raw) {
60446037
translateCluster(state, a, bx - ax, by - ay);
60456038
translateCluster(state, b, ax - bx, ay - by);
60466039
syncMovedEntities(state, [a.id, b.id], startPositions);
6047-
if (!raw) settle(state);
6040+
if (!raw) applyAutoAvoid(state);
60486041
return {
60496042
state: { ...state },
60506043
resolved: [
@@ -8108,10 +8101,10 @@ Usage: node sql2er-agent.mjs <command> [args] [--flags] (state in ./sql2er-sta
81088101
layout <optimal|arrange> Re-run a layout. optimal = planar skeleton seed + constrained stress
81098102
(rooms for attribute rings; the recommended default);
81108103
arrange = settle current positions.
8111-
move <id> <x> <y> Place a node. Entity attributes and diamonds follow. Then settles
8112-
with one arrange pass unless --raw.
8113-
nudge <id> <dx> <dy> Shift a node by a delta. --raw to skip the settle pass.
8114-
swap <idA> <idB> Exchange two entities' positions. --raw to skip settle.
8104+
move <id> <x> <y> Place a node. Entity attributes and diamonds follow. Then auto-avoids
8105+
unless --raw.
8106+
nudge <id> <dx> <dy> Shift a node by a delta. --raw to skip automatic avoidance.
8107+
swap <idA> <idB> Exchange two entities' positions. --raw to skip automatic avoidance.
81158108
rotate <degrees> Rotate the whole diagram about its centre (shapes stay upright).
81168109
attrs <auto|compact|moderate> Re-place attribute ellipses. compact = tightest
81178110
non-overlapping pack; moderate = uniform even ring. Persists.

‎src/test/sql2er-agent-cli.test.ts‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,78 @@ function makeAttributeConnectorCrossingState(): AgentState {
345345
};
346346
}
347347

348+
function makeEntityEditFollowState(): AgentState {
349+
return {
350+
version: 1,
351+
input: "manual",
352+
format: "sql",
353+
settings: {
354+
colored: true,
355+
comment: false,
356+
hideAttrs: false,
357+
fontScale: 1,
358+
attrMode: "moderate",
359+
autoAvoid: true,
360+
},
361+
nodes: [
362+
{ id: "entity-a", type: "entity", label: "a", nodeType: "entity", x: 100, y: 100 },
363+
{ id: "entity-b", type: "entity", label: "b", nodeType: "entity", x: 360, y: 140 },
364+
{
365+
id: "attr-a-name",
366+
type: "attribute",
367+
label: "name",
368+
nodeType: "attribute",
369+
parentEntity: "entity-a",
370+
x: 140,
371+
y: 172,
372+
},
373+
{
374+
id: "attr-b-name",
375+
type: "attribute",
376+
label: "name",
377+
nodeType: "attribute",
378+
parentEntity: "entity-b",
379+
x: 360,
380+
y: 20,
381+
},
382+
{
383+
id: "rel-a-b",
384+
type: "relationship",
385+
label: "a_b",
386+
nodeType: "relationship",
387+
x: 230,
388+
y: 120,
389+
},
390+
],
391+
edges: [
392+
{
393+
id: "edge-a-name",
394+
source: "entity-a",
395+
target: "attr-a-name",
396+
edgeType: "entity-attribute",
397+
},
398+
{
399+
id: "edge-b-name",
400+
source: "entity-b",
401+
target: "attr-b-name",
402+
edgeType: "entity-attribute",
403+
},
404+
{
405+
id: "edge-a-rel",
406+
source: "entity-a",
407+
target: "rel-a-b",
408+
edgeType: "entity-relationship",
409+
},
410+
{
411+
id: "edge-rel-b",
412+
source: "rel-a-b",
413+
target: "entity-b",
414+
edgeType: "relationship-entity",
415+
},
416+
],
417+
};
418+
}
419+
348420
function makeAttributeDetailState(): AgentState {
349421
return {
350422
version: 1,
@@ -1459,6 +1531,67 @@ describe("sql2er agent CLI entity edits", () => {
14591531
}
14601532
});
14611533

1534+
it("keeps entity attributes attached and relationship diamonds synced without settling default move and nudge edits", () => {
1535+
const cases = [
1536+
{
1537+
command: ["move", "entity-a", "180", "210"],
1538+
expectedEntity: { x: 180, y: 210 },
1539+
},
1540+
{
1541+
command: ["nudge", "entity-a", "80", "110"],
1542+
expectedEntity: { x: 180, y: 210 },
1543+
},
1544+
];
1545+
1546+
for (const testCase of cases) {
1547+
const dir = mkdtempSync(resolve(tmpdir(), "sql2er-agent-"));
1548+
try {
1549+
const state = resolve(dir, "er.json");
1550+
writeFileSync(state, JSON.stringify(makeEntityEditFollowState()));
1551+
1552+
const edited = runAgent([...testCase.command, "--state", state]);
1553+
1554+
expect(edited.status).toBe(0);
1555+
const next = readState(state);
1556+
expect(nodePosition(next, "entity-a")).toEqual(testCase.expectedEntity);
1557+
expect(nodePosition(next, "entity-b")).toEqual({ x: 360, y: 140 });
1558+
expect(nodePosition(next, "attr-a-name")).toEqual({ x: 220, y: 282 });
1559+
expect(nodePosition(next, "attr-b-name")).toEqual({ x: 360, y: 20 });
1560+
expectPointOnSegment(
1561+
nodePosition(next, "rel-a-b"),
1562+
nodePosition(next, "entity-a"),
1563+
nodePosition(next, "entity-b"),
1564+
);
1565+
} finally {
1566+
rmSync(dir, { recursive: true, force: true });
1567+
}
1568+
}
1569+
});
1570+
1571+
it("keeps entity attributes attached and relationship diamonds synced without settling default swaps", () => {
1572+
const dir = mkdtempSync(resolve(tmpdir(), "sql2er-agent-"));
1573+
try {
1574+
const state = resolve(dir, "er.json");
1575+
writeFileSync(state, JSON.stringify(makeEntityEditFollowState()));
1576+
1577+
const swapped = runAgent(["swap", "entity-a", "entity-b", "--state", state]);
1578+
1579+
expect(swapped.status).toBe(0);
1580+
const next = readState(state);
1581+
expect(nodePosition(next, "entity-a")).toEqual({ x: 360, y: 140 });
1582+
expect(nodePosition(next, "entity-b")).toEqual({ x: 100, y: 100 });
1583+
expect(nodePosition(next, "attr-a-name")).toEqual({ x: 400, y: 212 });
1584+
expect(nodePosition(next, "attr-b-name")).toEqual({ x: 100, y: -20 });
1585+
expectPointOnSegment(
1586+
nodePosition(next, "rel-a-b"),
1587+
nodePosition(next, "entity-a"),
1588+
nodePosition(next, "entity-b"),
1589+
);
1590+
} finally {
1591+
rmSync(dir, { recursive: true, force: true });
1592+
}
1593+
});
1594+
14621595
it("warns and leaves positions unchanged when swapping an attribute id", () => {
14631596
const dir = mkdtempSync(resolve(tmpdir(), "sql2er-agent-"));
14641597
try {

0 commit comments

Comments
 (0)