Skip to content

Commit 6392bf4

Browse files
[#51] Clear error for duplicate SerializedFile/archive names
Analyze only supports a single build at a time. A second SerializedFile or archive with a name already processed used to fail with a raw 'UNIQUE constraint failed' SQLite error (only visible with -v), and two archives sharing a name were silently recorded as duplicate rows. Detect these cases and report a distinctive, self-contained one-line message, skipping the offending file/archive and continuing. Enforce unique archive names at the schema level (bump user_version to 6) and add tests plus docs.
1 parent de3409a commit 6392bf4

8 files changed

Lines changed: 232 additions & 19 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using System;
2+
3+
namespace UnityDataTools.Analyzer;
4+
5+
// Thrown when analyze encounters a second SerializedFile or archive with a name it has already
6+
// processed. Only a single build can be analyzed at a time: a SerializedFile is referenced by name,
7+
// so two files sharing a name are indistinguishable to Unity's cross-file references. The message
8+
// is self-contained and leads with a distinctive phrase that is documented in command-analyze.md.
9+
public class AnalyzeDuplicateException : Exception
10+
{
11+
public string DuplicateName { get; }
12+
public bool IsArchive { get; }
13+
14+
public AnalyzeDuplicateException(string duplicateName, bool isArchive)
15+
: base(isArchive
16+
? $"Duplicate archive name '{duplicateName}'. Each analyzed archive must have a unique name; only a single build can be analyzed at a time."
17+
: $"Duplicate SerializedFile name '{duplicateName}'. Only a single build can be analyzed at a time; the same SerializedFile name cannot be analyzed twice.")
18+
{
19+
DuplicateName = duplicateName;
20+
IsArchive = isArchive;
21+
}
22+
}

Analyzer/AnalyzerTool.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ public int Analyze(AnalyzeOptions options)
121121
Console.Error.WriteLine($"Failed to open: {relativePath}");
122122
countFailures++;
123123
}
124+
catch (AnalyzeDuplicateException e)
125+
{
126+
// A file or archive with this name was already analyzed. Only a single build
127+
// can be analyzed at a time; print a clear one-line message (always visible,
128+
// not just with -v) and continue, counting this file as failed.
129+
EraseProgressLine();
130+
Console.Error.WriteLine($"Skipping {relativePath}: {e.Message}");
131+
countFailures++;
132+
}
124133
catch (Exception e)
125134
{
126135
// Unexpected failure (SQL error, I/O error, bug, etc.) — print full details.

Analyzer/Resources/Init.sql

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ CREATE TABLE IF NOT EXISTS types
88
-- Describes a unity archive that contains serialized files and other built content.
99
-- A common use of the unity archive is for AssetBundles but it can also be used for
1010
-- Player, Content Archive and ContentDirectory builds.
11+
-- name is UNIQUE: analyze only supports a single build, so two archives with the same name would
12+
-- make queries ambiguous. A duplicate is caught in code and reported (see AnalyzeDuplicateException);
13+
-- the constraint is the durable backstop for that invariant.
1114
CREATE TABLE IF NOT EXISTS archives
1215
(
1316
id INTEGER,
1417
name TEXT,
1518
file_size INTEGER,
16-
PRIMARY KEY (id)
19+
PRIMARY KEY (id),
20+
UNIQUE (name)
1721
);
1822

1923
-- One row per SerializedFile encountered during analysis. The name is often a technical,
@@ -198,8 +202,8 @@ INSERT INTO types (id, name) VALUES (-1, 'Scene');
198202
-- assetbundle_assets/preload_dependencies (issue #82); 3 = renamed asset_bundles table to archives
199203
-- and the asset_bundle column/alias to archive (issue #68); 4 = build_report_packed_asset_contents_view
200204
-- type column changed from numeric id to type name (issue #55); 5 = added dangling_refs table/view
201-
-- (issue #85); databases produced before versioning report 0.
202-
PRAGMA user_version = 5;
205+
-- (issue #85); 6 = archives.name is unique (issue #51); databases produced before versioning report 0.
206+
PRAGMA user_version = 6;
203207

204208
PRAGMA synchronous = OFF;
205209
PRAGMA journal_mode = MEMORY;

Analyzer/SQLite/Commands/SerializedFile/AddArchive.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ create table archives
99
id INTEGER,
1010
name TEXT,
1111
file_size INTEGER,
12-
PRIMARY KEY (id)
12+
PRIMARY KEY (id),
13+
UNIQUE (name)
1314
);
1415
*/
1516
internal class AddArchive : AbstractCommand

Analyzer/SQLite/Parsers/SerializedFileParser.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,18 @@ void ProcessFile(string file, string rootDirectory)
122122
// tracked separately so it isn't lumped with genuine processing errors.
123123
archiveHadMissingTypeTrees = true;
124124
}
125+
catch (AnalyzeDuplicateException e)
126+
{
127+
// A SerializedFile with this name was already analyzed (e.g. two
128+
// differently-named bundles containing the same CAB). Report the
129+
// self-contained message rather than a raw SQLite constraint error.
130+
Console.Error.WriteLine($"Skipping {node.Path} in archive {archiveName}: {e.Message}");
131+
archiveHadErrors = true;
132+
}
125133
catch (Exception e)
126134
{
127-
// the most likely exception here is Microsoft.Data.Sqlite.SqliteException,
128-
// for example 'UNIQUE constraint failed: serialized_files.id'.
129-
// or 'UNIQUE constraint failed: objects.id' which can happen
130-
// if AssetBundles from different builds are being processed by a single call to Analyze
131-
// or if there is a Unity Data Tool bug.
135+
// An unexpected error, for example a Unity Data Tool bug. Duplicate
136+
// names (the common 'UNIQUE constraint failed' case) are handled above.
132137
Console.Error.WriteLine($"Error processing {node.Path} in archive {archiveName}");
133138
Console.Error.WriteLine(e.Message);
134139
Console.Error.WriteLine();

Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ public class SerializedFileSQLiteWriter : IDisposable
1919
private int m_CurrentArchiveId = -1;
2020
private int m_NextArchiveId = 0;
2121

22+
// Names/ids already written to the archives and serialized_files tables, used to reject a
23+
// second copy of the same content with a clear error instead of a raw UNIQUE constraint
24+
// failure. Only a single build can be analyzed at a time (see AnalyzeDuplicateException).
25+
private HashSet<string> m_WrittenArchiveNames = new(StringComparer.OrdinalIgnoreCase);
26+
private HashSet<int> m_WrittenSerializedFileIds = new();
27+
2228
private bool m_SkipReferences;
2329
private bool m_SkipCrc;
2430

@@ -135,7 +141,17 @@ public void BeginArchive(string name, long size)
135141
throw new InvalidOperationException("SQLWriter.BeginArchive called twice");
136142
}
137143

144+
// Assign the id before the duplicate check throws, so the caller's EndArchive (called from
145+
// its finally) unwinds cleanly instead of masking the exception.
138146
m_CurrentArchiveId = m_NextArchiveId++;
147+
148+
// Reject a duplicate archive name so no second archives row is created and the caller can
149+
// report it before reading the archive's contents.
150+
if (!m_WrittenArchiveNames.Add(name))
151+
{
152+
throw new AnalyzeDuplicateException(name, isArchive: true);
153+
}
154+
139155
m_AddArchiveCommand.SetValue("id", m_CurrentArchiveId);
140156
m_AddArchiveCommand.SetValue("name", name);
141157
m_AddArchiveCommand.SetValue("file_size", size);
@@ -170,6 +186,14 @@ public void WriteSerializedFile(string relativePath, string fullPath, string con
170186
int serializedFileId = m_SerializedFileIdProvider.GetId(Path.GetFileName(fullPath).ToLowerInvariant());
171187
int sceneId = -1;
172188

189+
// Two SerializedFiles with the same name map to the same id (the provider deduplicates by
190+
// name), so a second one would collide on serialized_files.id. Reject it before opening a
191+
// transaction; the file name is what matters to the user, not the analyzer id.
192+
if (m_WrittenSerializedFileIds.Contains(serializedFileId))
193+
{
194+
throw new AnalyzeDuplicateException(Path.GetFileName(fullPath), isArchive: false);
195+
}
196+
173197
using var transaction = m_Database.BeginTransaction();
174198
m_CurrentTransaction = transaction;
175199

@@ -352,6 +376,7 @@ public void WriteSerializedFile(string relativePath, string fullPath, string con
352376
}
353377

354378
transaction.Commit();
379+
m_WrittenSerializedFileIds.Add(serializedFileId);
355380
}
356381
catch (Exception)
357382
{

Documentation/command-analyze.md

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -154,25 +154,36 @@ This error occurs when SerializedFiles are built without TypeTrees. The command
154154
UnityDataTool analyze /path/to/bundles --typetree-data /path/to/typetree.bin
155155
```
156156

157-
### SQL Constraint Errors
157+
### Duplicate SerializedFile name / Duplicate archive name
158158

159159
```
160-
SQLite Error 19: 'UNIQUE constraint failed: objects.id'
160+
Skipping build2\level0: Duplicate SerializedFile name 'level0'. Only a single build can be analyzed at a time; the same SerializedFile name cannot be analyzed twice.
161161
```
162162
or
163163
```
164-
SQLite Error 19: 'UNIQUE constraint failed: serialized_files.id'.
164+
Skipping build2\assetbundle: Duplicate archive name 'assetbundle'. Each analyzed archive must have a unique name; only a single build can be analyzed at a time.
165165
```
166166

167-
These errors occur when the same serialized file name appears in multiple sources:
167+
**analyze only supports a single build at a time.** Unity resolves references between SerializedFiles
168+
by file name, so two files that share a name are indistinguishable to those references — there is no
169+
way to tell which copy a reference points at. For that reason each SerializedFile name (and each
170+
archive name) may appear only once in a database.
168171

169-
| Cause | Solution |
170-
|-------|----------|
171-
| Multiple builds in same directory | Analyze each build separately |
172-
| Scenes with same filename (different paths) | Rename scenes to be unique |
173-
| AssetBundle variants | Analyze variants separately |
172+
When analyze encounters a second file or archive with a name it has already processed, it prints one
173+
of the messages above, **skips that file or archive** (counting it as a failed file), and continues
174+
with the rest of the input. The already-analyzed copy is kept; the duplicate's content is ignored.
174175

175-
See [Comparing Builds](../../Documentation/comparing-builds.md) for strategies to compare different versions of builds.
176+
This is expected when the input contains more than one build, and in these common cases:
177+
178+
| Cause | What to do |
179+
|-------|------------|
180+
| Multiple builds passed together (or nested in one directory) | Analyze each build into its own database |
181+
| AssetBundle variants (same content, different variant) | Analyze each variant separately |
182+
| Hashed AssetBundle file names across two builds | The file names differ but the inner SerializedFile (`CAB-<hash>`) is shared — analyze each build separately |
183+
| Player scenes with the same file name (`level0`, …) from different builds | Analyze each build separately |
184+
185+
To compare two builds, analyze each into a separate database and query across them — see
186+
[Comparing Builds](../../Documentation/comparing-builds.md).
176187

177188
### Slow Analyze times, large output database
178189

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using System.IO;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using Microsoft.Data.Sqlite;
5+
using NUnit.Framework;
6+
7+
namespace UnityDataTools.UnityDataTool.Tests;
8+
9+
#pragma warning disable NUnit2005, NUnit2006
10+
11+
// Tests the duplicate-name handling (issue #51): analyze supports only a single build, so a second
12+
// SerializedFile or archive with a name that was already processed is skipped with a clear
13+
// single-line message instead of a raw "UNIQUE constraint failed" SQLite error. Covers the three
14+
// scenarios from the issue: loose files, archives with the same name, and differently-named
15+
// archives (hashed bundle names) that share the same inner SerializedFile.
16+
public class AnalyzeDuplicateNameTests
17+
{
18+
private string m_TestOutputFolder;
19+
private string m_AssetBundlesFolder;
20+
21+
[OneTimeSetUp]
22+
public void OneTimeSetup()
23+
{
24+
m_TestOutputFolder = Path.Combine(TestContext.CurrentContext.TestDirectory, "duplicate_name_test_folder");
25+
m_AssetBundlesFolder = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "AssetBundles");
26+
Directory.CreateDirectory(m_TestOutputFolder);
27+
Directory.SetCurrentDirectory(m_TestOutputFolder);
28+
}
29+
30+
[TearDown]
31+
public void Teardown()
32+
{
33+
SqliteConnection.ClearAllPools();
34+
var testDir = new DirectoryInfo(m_TestOutputFolder);
35+
testDir.EnumerateFiles().ToList().ForEach(f => f.Delete());
36+
testDir.EnumerateDirectories().ToList().ForEach(d => d.Delete(true));
37+
}
38+
39+
// Runs analyze and returns its exit code plus whatever it wrote to stderr (where the
40+
// duplicate messages are printed).
41+
private static async Task<(int exitCode, string stderr)> RunAnalyze(params string[] args)
42+
{
43+
var originalError = System.Console.Error;
44+
using var sw = new StringWriter();
45+
try
46+
{
47+
System.Console.SetError(sw);
48+
var exitCode = await Program.Main(new[] { "analyze" }.Concat(args).ToArray());
49+
return (exitCode, sw.ToString());
50+
}
51+
finally
52+
{
53+
System.Console.SetError(originalError);
54+
}
55+
}
56+
57+
// Case 2 / Case 3: two build folders each contain an archive named "assetbundle" (and "scenes").
58+
// The second archive of each name is rejected before it is opened, so exactly one row per name
59+
// survives and no UNIQUE constraint error is shown.
60+
[Test]
61+
public async Task Analyze_ArchivesWithSameName_SkippedWithClearMessage()
62+
{
63+
var build1 = Path.Combine(m_AssetBundlesFolder, "2019.4.0f1");
64+
var build2 = Path.Combine(m_AssetBundlesFolder, "2020.3.0f1");
65+
var databasePath = SQLTestHelper.GetDatabasePath(m_TestOutputFolder);
66+
67+
var (exitCode, stderr) = await RunAnalyze(build1, build2, "-o", databasePath);
68+
69+
Assert.AreEqual(0, exitCode, "analyze should continue and exit 0 after skipping duplicates");
70+
StringAssert.Contains("Duplicate archive name 'assetbundle'", stderr);
71+
StringAssert.DoesNotContain("UNIQUE constraint", stderr);
72+
73+
using var db = SQLTestHelper.OpenDatabase(databasePath);
74+
SQLTestHelper.AssertQueryInt(db,
75+
"SELECT COUNT(*) FROM archives WHERE name = 'assetbundle'",
76+
1, "only one archive named 'assetbundle' should be recorded");
77+
}
78+
79+
// Case 1: two loose SerializedFiles with the same name in different folders. The duplicate is
80+
// rejected before its transaction is opened, so only the first copy is recorded.
81+
[Test]
82+
public async Task Analyze_LooseFilesWithSameName_SkippedWithClearMessage()
83+
{
84+
var source = Path.Combine(TestContext.CurrentContext.TestDirectory,
85+
"Data", "PlayerWithTypeTrees", "level0");
86+
var build1 = Path.Combine(m_TestOutputFolder, "build1");
87+
var build2 = Path.Combine(m_TestOutputFolder, "build2");
88+
Directory.CreateDirectory(build1);
89+
Directory.CreateDirectory(build2);
90+
File.Copy(source, Path.Combine(build1, "level0"));
91+
File.Copy(source, Path.Combine(build2, "level0"));
92+
var databasePath = SQLTestHelper.GetDatabasePath(m_TestOutputFolder);
93+
94+
var (exitCode, stderr) = await RunAnalyze(m_TestOutputFolder, "-o", databasePath);
95+
96+
Assert.AreEqual(0, exitCode, "analyze should continue and exit 0 after skipping duplicates");
97+
StringAssert.Contains("Duplicate SerializedFile name 'level0'", stderr);
98+
StringAssert.DoesNotContain("UNIQUE constraint", stderr);
99+
100+
using var db = SQLTestHelper.OpenDatabase(databasePath);
101+
SQLTestHelper.AssertQueryInt(db,
102+
"SELECT COUNT(*) FROM serialized_files WHERE name = 'level0'",
103+
1, "only one SerializedFile named 'level0' should be recorded");
104+
}
105+
106+
// Hashed-name shape: the same archive under two different file names (as with hashed bundle
107+
// names). The archive names differ, so both are recorded, but they share the same inner
108+
// SerializedFile ("CAB-<hash>"), which is rejected the second time.
109+
[Test]
110+
public async Task Analyze_DifferentArchiveNamesSharingSerializedFile_SkippedWithClearMessage()
111+
{
112+
var source = Path.Combine(m_AssetBundlesFolder, "2019.4.0f1", "assetbundle");
113+
var bundleA = Path.Combine(m_TestOutputFolder, "bundleA");
114+
var bundleB = Path.Combine(m_TestOutputFolder, "bundleB");
115+
File.Copy(source, bundleA);
116+
File.Copy(source, bundleB);
117+
var databasePath = SQLTestHelper.GetDatabasePath(m_TestOutputFolder);
118+
119+
var (exitCode, stderr) = await RunAnalyze(bundleA, bundleB, "-o", databasePath);
120+
121+
Assert.AreEqual(0, exitCode, "analyze should continue and exit 0 after skipping duplicates");
122+
StringAssert.Contains("Duplicate SerializedFile name", stderr);
123+
StringAssert.DoesNotContain("UNIQUE constraint", stderr);
124+
125+
using var db = SQLTestHelper.OpenDatabase(databasePath);
126+
SQLTestHelper.AssertQueryInt(db,
127+
"SELECT COUNT(*) FROM archives WHERE name IN ('bundleA', 'bundleB')",
128+
2, "both differently-named archives should be recorded");
129+
// The shared inner SerializedFile is analyzed once; count only files that actually have
130+
// objects, since references also create name-only stub rows for un-analyzed files.
131+
SQLTestHelper.AssertQueryInt(db,
132+
@"SELECT COUNT(*) FROM serialized_files
133+
WHERE name LIKE 'CAB-%' AND id IN (SELECT serialized_file FROM objects)",
134+
1, "the shared inner SerializedFile should be analyzed only once");
135+
}
136+
}

0 commit comments

Comments
 (0)