Skip to content
Merged
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
51 changes: 51 additions & 0 deletions src/lib/models/model-dependency-tree-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,43 @@ import ansiColors from "ansi-colors";
import { SitemapHierarchy } from "../pushers/page-pusher/sitemap-hierarchy";
import { AssetReferenceExtractor } from "../assets/asset-reference-extractor";

/**
* Expand a list of model reference names to include every model they reference through
* linked-content fields.
*/
export function resolveReferencedModels(modelNames: string[], allModels: any[]): string[] {
const byLower = new Map<string, any>();
(allModels || []).forEach((m: any) => {
if (m?.referenceName) byLower.set(m.referenceName.toLowerCase(), m);
});

const result = new Set<string>();
const queue: string[] = [];

const add = (name: string | undefined | null) => {
if (!name || typeof name !== "string") return;
const model = byLower.get(name.toLowerCase());
const canonical = model?.referenceName ?? name;
if (!result.has(canonical)) {
result.add(canonical);
queue.push(canonical);
}
};

(modelNames || []).forEach(add);

while (queue.length > 0) {
const name = queue.pop()!;
const model = byLower.get(name.toLowerCase());
if (!model || !Array.isArray(model.fields)) continue;
for (const field of model.fields) {
add(field?.settings?.ContentDefinition);
}
}

return Array.from(result);
}

export interface ModelDependencyTree {
models: Set<string>; // Model reference names
containers: Set<number>;
Expand Down Expand Up @@ -75,6 +112,10 @@ export class ModelDependencyTreeBuilder {
this.findTemplatesUsedByPages(tree);
// 🎯 NEW: Include models for the newly discovered content
this.findModelsForDiscoveredContent(tree);
// 🎯 NEW: Include models referenced by other models via linked-content fields
// (e.g. FooterLinks → FooterLinksLists). Without this, a selected model whose field points at
// another model fails to save on the target with a 404 "Definition for setting X not found".
this.findModelsReferencedByModels(tree);
// 🎯 NEW: Include containers for the newly discovered models
this.findContainersForDiscoveredModels(tree);
// 🎯 NEW: Include containers that contain discovered content items
Expand Down Expand Up @@ -345,6 +386,16 @@ export class ModelDependencyTreeBuilder {
// console.log(ansiColors.gray(` 📋 Added ${newModelCount} additional models for content dependencies`));
}

/**
* Expand the model set with models referenced by other models through linked-content fields.
* Delegates to the shared `resolveReferencedModels` walk (transitive, de-duplicated, cycle-safe).
*/
private findModelsReferencedByModels(tree: ModelDependencyTree): void {
if (!this.sourceData.models) return;
const expanded = resolveReferencedModels(Array.from(tree.models), this.sourceData.models);
expanded.forEach((name) => tree.models.add(name));
}

/**
* Find containers for newly discovered models
*/
Expand Down
57 changes: 57 additions & 0 deletions src/lib/models/tests/model-dependency-tree-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,63 @@ function makeSourceData(overrides: Partial<any> = {}): any {
};
}

// ─── model→model references via linked-content fields (PROD-2187) ─────────────

function makeModelWithRefs(id: number, referenceName: string, refs: string[] = []): any {
return {
id,
referenceName,
fields: refs.map((r) => ({ type: "Content", settings: { ContentDefinition: r } })),
};
}

describe("ModelDependencyTreeBuilder — model→model references", () => {
it("includes a model referenced via a linked-content field (FooterLinks → FooterLinksLists)", () => {
const builder = new ModelDependencyTreeBuilder(
makeSourceData({
models: [makeModelWithRefs(1, "FooterLinks", ["FooterLinksLists"]), makeModelWithRefs(2, "FooterLinksLists")],
})
);
const tree = builder.buildDependencyTree(["FooterLinks"], "website");
expect(tree.models.has("FooterLinks")).toBe(true);
expect(tree.models.has("FooterLinksLists")).toBe(true);
});

it("resolves references transitively (A → B → C)", () => {
const builder = new ModelDependencyTreeBuilder(
makeSourceData({
models: [makeModelWithRefs(1, "A", ["B"]), makeModelWithRefs(2, "B", ["C"]), makeModelWithRefs(3, "C")],
})
);
const tree = builder.buildDependencyTree(["A"], "website");
expect(tree.models.has("B")).toBe(true);
expect(tree.models.has("C")).toBe(true);
});

it("does not pull in unrelated models", () => {
const builder = new ModelDependencyTreeBuilder(
makeSourceData({
models: [
makeModelWithRefs(1, "FooterLinks", ["FooterLinksLists"]),
makeModelWithRefs(2, "FooterLinksLists"),
makeModelWithRefs(9, "Unrelated"),
],
})
);
const tree = builder.buildDependencyTree(["FooterLinks"], "website");
expect(tree.models.has("Unrelated")).toBe(false);
});

it("terminates on a reference cycle (A → B → A)", () => {
const builder = new ModelDependencyTreeBuilder(
makeSourceData({ models: [makeModelWithRefs(1, "A", ["B"]), makeModelWithRefs(2, "B", ["A"])] })
);
const tree = builder.buildDependencyTree(["A"], "website");
expect(tree.models.has("A")).toBe(true);
expect(tree.models.has("B")).toBe(true);
});
});

// ─── resetLoggingFlags ────────────────────────────────────────────────────────

describe("ModelDependencyTreeBuilder.resetLoggingFlags", () => {
Expand Down
18 changes: 14 additions & 4 deletions src/lib/pushers/guid-data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import { fileOperations } from "../../core/fileOperations";
import { getState } from "../../core/state";
import * as mgmtApi from "@agility/management-sdk";

// Shared model→model reference resolver, defined alongside the dependency-tree builder so both the
// --models (here) and --models-with-deps (tree builder) paths use the same logic.
import { resolveReferencedModels } from "../models/model-dependency-tree-builder";
export { resolveReferencedModels };

export interface ModelFilterOptions {
models?: string[]; // Simple model filtering
modelsWithDeps?: string[]; // Model filtering with dependency tree
Expand Down Expand Up @@ -202,13 +207,18 @@ export class GuidDataLoader {
);
}

// Build dependency tree and filter all related entities using complete data
const dependencyTree = treeBuilder.buildDependencyTree(validation.valid, locale);

// --models (simple): pull ONLY the requested models plus the models they reference through
// linked-content fields (e.g. FooterLinks → FooterLinksLists), transitively. No content, pages,
// containers, or assets. Referenced models must be included or the model push fails on the target
// with a 404 "Definition for setting X not found".
if (!useFullDependencyTree) {
return this.filterGuidEntitiesByModels(guidEntities, validation.valid);
const expandedModels = resolveReferencedModels(validation.valid, (completeEntities ?? guidEntities).models);
return this.filterGuidEntitiesByModels(guidEntities, expandedModels);
}

// Build dependency tree and filter all related entities using complete data
const dependencyTree = treeBuilder.buildDependencyTree(validation.valid, locale);

return await this.filterGuidEntitiesByDependencyTree(completeEntities, dependencyTree, locale);
}

Expand Down
103 changes: 102 additions & 1 deletion src/lib/pushers/tests/guid-data-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { resetState, setState, state } from "core/state";
import { GuidDataLoader } from "../guid-data-loader";
import { GuidDataLoader, resolveReferencedModels } from "../guid-data-loader";

let tmpDir: string;

Expand Down Expand Up @@ -211,6 +211,64 @@ describe("GuidDataLoader.validateDataStructure", () => {
});
});

// ─── resolveReferencedModels (PROD-2187: --models pulls referenced models) ───

describe("resolveReferencedModels", () => {
const contentField = (refName: string) => ({ type: "Content", settings: { ContentDefinition: refName } });
const model = (referenceName: string, refs: string[] = []) => ({
referenceName,
fields: refs.map(contentField),
});

it("returns just the requested model when it references nothing", () => {
const all = [model("FooterLinksLists")];
expect(resolveReferencedModels(["FooterLinksLists"], all)).toEqual(["FooterLinksLists"]);
});

it("includes a model referenced via a linked-content field (FooterLinks → FooterLinksLists)", () => {
const all = [model("FooterLinks", ["FooterLinksLists"]), model("FooterLinksLists")];
const result = resolveReferencedModels(["FooterLinks"], all);
expect(result).toEqual(expect.arrayContaining(["FooterLinks", "FooterLinksLists"]));
expect(result).toHaveLength(2);
});

it("resolves references transitively (A → B → C)", () => {
const all = [model("A", ["B"]), model("B", ["C"]), model("C")];
const result = resolveReferencedModels(["A"], all);
expect(result).toEqual(expect.arrayContaining(["A", "B", "C"]));
expect(result).toHaveLength(3);
});

it("terminates on a reference cycle (A → B → A)", () => {
const all = [model("A", ["B"]), model("B", ["A"])];
const result = resolveReferencedModels(["A"], all);
expect(result.sort()).toEqual(["A", "B"]);
});

it("matches case-insensitively but returns the canonical reference name", () => {
const all = [model("FooterLinks", ["FooterLinksLists"]), model("FooterLinksLists")];
const result = resolveReferencedModels(["footerlinks"], all);
expect(result).toEqual(expect.arrayContaining(["FooterLinks", "FooterLinksLists"]));
});

it("keeps a requested model even if it is not found in the model set", () => {
expect(resolveReferencedModels(["Ghost"], [])).toEqual(["Ghost"]);
});

it("ignores fields with empty/absent ContentDefinition", () => {
const all = [
{
referenceName: "M",
fields: [
{ type: "Text", settings: {} },
{ type: "Content", settings: { ContentDefinition: "" } },
],
},
];
expect(resolveReferencedModels(["M"], all)).toEqual(["M"]);
});
});

// ─── loadGuidEntities — with prepared filesystem ─────────────────────────────

describe("GuidDataLoader.loadGuidEntities", () => {
Expand Down Expand Up @@ -291,4 +349,47 @@ describe("GuidDataLoader.loadGuidEntities", () => {
/Model validation failed/
);
});

it("--models pulls the requested model AND its referenced models, but no content/pages/containers", async () => {
// Lay down models on disk: FooterLinks references FooterLinksLists via a linked-content field.
const guid = "models-only-refs-guid-u";
const modelsDir = path.join(tmpDir, guid, "models");
fs.mkdirSync(modelsDir, { recursive: true });
fs.writeFileSync(
path.join(modelsDir, "157.json"),
JSON.stringify({
id: 157,
referenceName: "FooterLinks",
contentDefinitionTypeID: 1,
fields: [{ name: "footerLinks", type: "Content", settings: { ContentDefinition: "FooterLinksLists" } }],
})
);
fs.writeFileSync(
path.join(modelsDir, "158.json"),
JSON.stringify({ id: 158, referenceName: "FooterLinksLists", contentDefinitionTypeID: 1, fields: [] })
);
// A model that was NOT requested and is unrelated — must NOT be pulled in.
fs.writeFileSync(
path.join(modelsDir, "999.json"),
JSON.stringify({ id: 999, referenceName: "Unrelated", contentDefinitionTypeID: 1, fields: [] })
);

state.elements = "Models";
state.isSync = false;
state.modelsWithDeps = "";

const loader = new GuidDataLoader(guid);
const entities = await loader.loadGuidEntities("en-us", { models: ["FooterLinks"] });

const names = entities.models.map((m: any) => m.referenceName).sort();
expect(names).toEqual(["FooterLinks", "FooterLinksLists"]); // referenced model included, Unrelated excluded

// Models-only: nothing else is pulled.
expect(entities.containers).toHaveLength(0);
expect(entities.content).toHaveLength(0);
expect(entities.pages).toHaveLength(0);
expect(entities.templates).toHaveLength(0);
expect(entities.assets).toHaveLength(0);
expect(entities.galleries).toHaveLength(0);
});
});
Loading