From b38f9b039077dceab859189f3993600c0c1e6c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:55:09 +0900 Subject: [PATCH 01/28] =?UTF-8?q?refactor:=20=E6=9C=AA=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E3=81=AE=E5=A4=96=E9=83=A8=E3=82=AD=E3=83=BC=E5=88=B6=E7=B4=84?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CSVLoader/Editor/CsvInspectorEditor.cs | 2 - .../CsvValidation/CsvValidationAttributes.cs | 28 --------- .../CsvValidation/CsvValidationContext.cs | 44 -------------- .../CsvValidationContext.cs.meta | 2 - .../CsvValidation/CsvValidationSchema.cs | 5 -- .../Runtime/CsvValidation/CsvValidator.cs | 60 +------------------ 6 files changed, 3 insertions(+), 138 deletions(-) delete mode 100644 Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationContext.cs delete mode 100644 Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationContext.cs.meta diff --git a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs index 8a663c1..5a47ff0 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs @@ -314,8 +314,6 @@ private static string GetAttributeDisplayText(Attribute attribute) return $"[MinLength: {minLength.MinLength}]"; case MaxLengthAttribute maxLength: return $"[MaxLength: {maxLength.MaxLength}]"; - case ForeignKeyAttribute foreignKey: - return $"[ForeignKey: {foreignKey.ReferenceEnumType.Name}.{foreignKey.ReferenceField}]"; default: return null; } diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs index d028459..88f79d8 100644 --- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs +++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs @@ -113,34 +113,6 @@ public AllowedValuesAttribute(params object[] allowedValues) } } - /// - /// セル文字列が参照先テーブルの指定列に存在することを要求します。 - /// - /// - /// 同じEnum型を指定した場合は検証対象テーブル内を参照します。別のEnum型を指定する場合は、 - /// で参照先を登録します。 - /// - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class ForeignKeyAttribute : Attribute - { - /// 参照先テーブルを識別するEnum型を取得します。 - public Type ReferenceEnumType { get; } - - /// 参照先のヘッダー名を取得します。 - public string ReferenceField { get; } - - /// - /// 外部キー制約を設定します。 - /// - /// 参照先テーブルを識別するEnum型。 - /// 参照先のヘッダー名。 - public ForeignKeyAttribute(Type referenceEnumType, string referenceField) - { - ReferenceEnumType = referenceEnumType; - ReferenceField = referenceField; - } - } - /// /// セル文字列が指定した最小長以上であることを要求します。 /// diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationContext.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationContext.cs deleted file mode 100644 index 1683ea0..0000000 --- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationContext.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace CSV4Unity.Validation -{ - /// - /// ForeignKey検証で参照する別CSVテーブルを保持します。 - /// - /// 参照先はEnum型をキーとして保持し、同じ型を再登録した場合は後のテーブルで置き換えます。 - public sealed class CsvValidationContext - { - private readonly Dictionary _documents = new Dictionary(); - - /// Enum型を識別子として参照先テーブルを登録します。 - /// 参照先テーブルの列を表すEnum型。 - /// 登録する参照先テーブル。 - /// 連続して登録できるよう、このContext自身を返します。 - /// です。 - public CsvValidationContext Register(CsvTable table) where TField : struct, Enum - { - if (table == null) throw new ArgumentNullException(nameof(table)); - _documents[typeof(TField)] = table.Document; - return this; - } - - internal bool TryGetColumn(Type enumType, string fieldName, out CsvColumn column) - { - if (_documents.TryGetValue(enumType, out CsvDocument document)) - { - try - { - column = document.Column(fieldName); - return true; - } - catch (KeyNotFoundException) - { - } - } - - column = default; - return false; - } - } -} diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationContext.cs.meta b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationContext.cs.meta deleted file mode 100644 index c48d8b7..0000000 --- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationContext.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f3ba2c68f6acaa74dad9131c9425750d \ No newline at end of file diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs index 23985f7..0a598df 100644 --- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs +++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs @@ -101,10 +101,6 @@ private static CsvFieldValidationRule CreateRule(FieldInfo fieldInfo, ob rule.MaxLength = maxLength.MaxLength; hasConstraint = true; break; - case ForeignKeyAttribute foreignKey: - rule.ForeignKey = foreignKey; - hasConstraint = true; - break; } } @@ -137,6 +133,5 @@ internal sealed class CsvFieldValidationRule where TField : struct, Enum public HashSet AllowedValues { get; set; } public int? MinLength { get; set; } public int? MaxLength { get; set; } - public ForeignKeyAttribute ForeignKey { get; set; } } } diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidator.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidator.cs index 36e9348..272f87d 100644 --- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidator.cs +++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidator.cs @@ -18,19 +18,16 @@ public static class CsvValidator /// /// 使用するValidationスキーマ。の場合はを使用します。 /// - /// ForeignKeyの参照先テーブル。参照先が同じテーブルだけの場合はにできます。 /// 型変換と数値範囲検証に使用する形式。の場合はを使用します。 /// すべてのエラーとWarningを格納したValidation結果。 /// です。 /// /// PrimaryKeyは空でなく一意、Uniqueは空セルを除いて一意であることを大文字小文字を区別して検証します。 - /// ForeignKeyの参照テーブルまたは列を解決できない場合、その制約を実行せずWarningを追加します。 /// 空セルはPrimaryKeyとNotNullを除くセル単位制約の対象外です。 /// public static CsvValidationResult Validate( CsvTable table, CsvValidationSchema validationSchema = null, - CsvValidationContext context = null, IFormatProvider formatProvider = null) where TField : struct, Enum { @@ -43,7 +40,7 @@ public static CsvValidationResult Validate( IReadOnlyList> rules = schema.Rules; for (int i = 0; i < rules.Count; i++) { - ValidateField(table, rules[i], context, provider, result); + ValidateField(table, rules[i], provider, result); } return result; @@ -52,7 +49,6 @@ public static CsvValidationResult Validate( private static void ValidateField( CsvTable table, CsvFieldValidationRule rule, - CsvValidationContext context, IFormatProvider formatProvider, CsvValidationResult result) where TField : struct, Enum @@ -69,8 +65,6 @@ private static void ValidateField( ValidateDistinct(column, rule.FieldName, false, result); } - HashSet referenceValues = PrepareForeignKeyValues(table, rule, context, result); - for (int rowIndex = 0; rowIndex < column.Count; rowIndex++) { CsvCell cell = column[rowIndex]; @@ -82,7 +76,7 @@ private static void ValidateField( if (cell.IsEmpty) continue; - ValidateCell(cell, rowIndex, rule, formatProvider, referenceValues, result); + ValidateCell(cell, rowIndex, rule, formatProvider, result); } } @@ -91,7 +85,6 @@ private static void ValidateCell( int rowIndex, CsvFieldValidationRule rule, IFormatProvider formatProvider, - HashSet referenceValues, CsvValidationResult result) where TField : struct, Enum { @@ -122,8 +115,7 @@ private static void ValidateCell( } bool requiresString = rule.Pattern != null || rule.AllowedValues != null || - rule.MinLength.HasValue || rule.MaxLength.HasValue || - referenceValues != null; + rule.MinLength.HasValue || rule.MaxLength.HasValue; if (!requiresString) return; string text = cell.GetString(); @@ -152,11 +144,6 @@ private static void ValidateCell( rule.FieldName, $"Length {text.Length} exceeds the maximum {rule.MaxLength.Value}."); } - - if (referenceValues != null && !referenceValues.Contains(text)) - { - result.AddError(rowIndex, rule.FieldName, $"Referenced value '{text}' was not found."); - } } private static void ValidateDistinct( @@ -189,46 +176,5 @@ private static void ValidateDistinct( } } } - - private static HashSet PrepareForeignKeyValues( - CsvTable table, - CsvFieldValidationRule rule, - CsvValidationContext context, - CsvValidationResult result) - where TField : struct, Enum - { - if (rule.ForeignKey == null) return null; - - CsvColumn referenceColumn; - if (rule.ForeignKey.ReferenceEnumType == typeof(TField)) - { - try - { - referenceColumn = table.Document.Column(rule.ForeignKey.ReferenceField); - } - catch (KeyNotFoundException) - { - result.AddWarning(-1, rule.FieldName, "Foreign key reference column was not found."); - return null; - } - } - else if (context == null || !context.TryGetColumn( - rule.ForeignKey.ReferenceEnumType, - rule.ForeignKey.ReferenceField, - out referenceColumn)) - { - result.AddWarning(-1, rule.FieldName, "Foreign key reference table is not registered."); - return null; - } - - var values = new HashSet(StringComparer.Ordinal); - for (int rowIndex = 0; rowIndex < referenceColumn.Count; rowIndex++) - { - CsvCell cell = referenceColumn[rowIndex]; - if (!cell.IsEmpty) values.Add(cell.GetString()); - } - - return values; - } } } From da30960f6f85daaa126298a09869b44d516b49cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:55:19 +0900 Subject: [PATCH 02/28] =?UTF-8?q?test:=20=E5=A4=96=E9=83=A8=E3=82=AD?= =?UTF-8?q?=E3=83=BC=E5=88=B6=E7=B4=84=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/EditMode/CsvValidationTableTests.cs | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs b/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs index 4b7d618..d59ddf2 100644 --- a/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs +++ b/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs @@ -32,18 +32,6 @@ private enum CharacterField Kind } - private enum ItemField - { - [PrimaryKey] - Id - } - - private enum ScenarioReferenceField - { - [ForeignKey(typeof(ItemField), nameof(ItemField.Id))] - ItemId - } - private enum FixtureValidationField { [PrimaryKey] @@ -104,32 +92,5 @@ public void Validate_InvalidFixture_ReportsExpectedErrors() Assert.That(result.Errors.Count, Is.EqualTo(6)); } - [Test] - public void Validate_ForeignKey_UsesRegisteredReferenceTable() - { - CsvTable items = CsvParser.Parse("Id\nA\nB").WithFields(); - CsvTable scenario = CsvParser.Parse("ItemId\nA\nC") - .WithFields(); - var context = new CsvValidationContext().Register(items); - - CsvValidationResult result = CsvValidator.Validate(scenario, context: context); - - Assert.That(result.Errors.Count, Is.EqualTo(1)); - Assert.That(result.Errors[0].Row, Is.EqualTo(1)); - Assert.That(result.Errors[0].Column, Is.EqualTo(nameof(ScenarioReferenceField.ItemId))); - } - - [Test] - public void Validate_ForeignKeyWithoutContext_ReportsOneWarning() - { - CsvTable scenario = CsvParser.Parse("ItemId\nA") - .WithFields(); - - CsvValidationResult result = CsvValidator.Validate(scenario); - - Assert.That(result.IsValid, Is.True); - Assert.That(result.Warnings.Count, Is.EqualTo(1)); - Assert.That(result.Warnings[0].Row, Is.EqualTo(-1)); - } } } From 7af01ff72010775e63df5224c8fbe49e38dfe875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:55:19 +0900 Subject: [PATCH 03/28] =?UTF-8?q?docs:=20Validation=E3=81=AE=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E7=AF=84=E5=9B=B2=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/en/architecture.md | 3 +-- docs/ja/architecture.md | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 14e4eca..e53d826 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ foreach (ValidationError error in result.Errors) } ``` -利用可能な制約は `PrimaryKey`、`NotNull`、`Unique`、`TypeConstraint`、`Range`、`Regex`、`AllowedValues`、`MinLength`、`MaxLength`、`ForeignKey` です。 +利用可能な制約は `PrimaryKey`、`NotNull`、`Unique`、`TypeConstraint`、`Range`、`Regex`、`AllowedValues`、`MinLength`、`MaxLength` です。 ## Inspector Validation diff --git a/docs/en/architecture.md b/docs/en/architecture.md index a08bf43..61e3d73 100644 --- a/docs/en/architecture.md +++ b/docs/en/architecture.md @@ -121,12 +121,11 @@ Responsibility: adapt Unity inputs to the pure C# core. - Delegates all parsing to `CsvParser`. - Does not contain parsing, conversion, indexing, or validation algorithms. -## Planned validation boundary +## Validation boundary Attribute metadata will be compiled into a validation schema once, then applied to `CsvTable`. Row-local rules and column/table rules must be separate: - Row-local: required, type, range, regex, allowed values, length. - Column/table: primary key and unique. -- Cross-document: foreign key through an explicit validation context. This prevents `Unique` from rescanning an entire column once per row and prevents the core data model from depending on reflection or validation attributes. diff --git a/docs/ja/architecture.md b/docs/ja/architecture.md index 3a69128..0c618c0 100644 --- a/docs/ja/architecture.md +++ b/docs/ja/architecture.md @@ -188,16 +188,14 @@ if (index.TryFindFirst("Text", out int rowIndex)) |---|---| | `CsvValidationSchema` | Enum属性を一度読み取り、検証規則へ変換する | | `CsvValidator` | `CsvTable` を規則に従って検証する | -| `CsvValidationContext` | 外部キー検証で参照先CSVを登録する | | `CsvValidationResult` | エラーと警告を保持する | -Validationは次の3種類へ分けます。 +Validationは次の2種類へ分けます。 | 種類 | 制約 | |---|---| | セル・行単位 | `NotNull`、`TypeConstraint`、`Range`、`Regex`、`AllowedValues`、文字列長 | | 列全体 | `PrimaryKey`、`Unique` | -| CSV間 | `ForeignKey` | `PrimaryKey` と `Unique` は、各行の検証中に列全体を繰り返し走査せず、列ごとに一度だけ検証します。 From 423a307b0ae070113e55c3f54efa6c4102e04ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:31:04 +0900 Subject: [PATCH 04/28] =?UTF-8?q?feat:=20=E6=9D=A1=E4=BB=B6=E4=BB=98?= =?UTF-8?q?=E3=81=8D=E3=83=90=E3=83=AA=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CSVLoader/Editor/CsvInspectorEditor.cs | 43 ++- .../CsvValidation/CsvConditionEvaluator.cs | 232 +++++++++++++ .../CsvConditionEvaluator.cs.meta | 2 + .../CsvValidation/CsvValidationAttributes.cs | 137 ++++++-- .../CsvValidation/CsvValidationSchema.cs | 310 ++++++++++++++---- .../Runtime/CsvValidation/CsvValidator.cs | 179 ++++++---- .../Runtime/Schema/CsvSchemaException.cs | 2 +- 7 files changed, 742 insertions(+), 163 deletions(-) create mode 100644 Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs create mode 100644 Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs.meta diff --git a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs index 8a663c1..50d858f 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs @@ -294,31 +294,54 @@ private static string GetSelectionKey(string assetPath) private static string GetAttributeDisplayText(Attribute attribute) { + string label; switch (attribute) { + case ConditionAttribute condition: + string values = condition.Values.Length == 0 + ? string.Empty + : $" {string.Join("|", condition.Values)}"; + string group = condition.Group == 0 ? string.Empty : $" Group {condition.Group}:"; + return $"[Condition:{group} {condition.Field} {condition.Comparison}{values}]"; case PrimaryKeyAttribute: - return "[PrimaryKey]"; + label = "PrimaryKey"; + break; case NotNullAttribute: - return "[NotNull]"; + label = "NotNull"; + break; case UniqueAttribute: - return "[Unique]"; + label = "Unique"; + break; case TypeConstraintAttribute typeConstraint: - return $"[Type: {typeConstraint.ExpectedType.Name}]"; + label = $"Type: {typeConstraint.ExpectedType.Name}"; + break; case Validation.RangeAttribute range: - return $"[Range: {range.Min}-{range.Max}]"; + label = $"Range: {range.Min}-{range.Max}"; + break; case RegexAttribute regex: - return $"[Regex: {regex.Pattern}]"; + label = $"Regex: {regex.Pattern}"; + break; case AllowedValuesAttribute allowed: - return $"[Allowed: {string.Join("|", allowed.AllowedValues)}]"; + label = $"Allowed: {string.Join("|", allowed.AllowedValues)}"; + break; case MinLengthAttribute minLength: - return $"[MinLength: {minLength.MinLength}]"; + label = $"MinLength: {minLength.MinLength}"; + break; case MaxLengthAttribute maxLength: - return $"[MaxLength: {maxLength.MaxLength}]"; + label = $"MaxLength: {maxLength.MaxLength}"; + break; case ForeignKeyAttribute foreignKey: - return $"[ForeignKey: {foreignKey.ReferenceEnumType.Name}.{foreignKey.ReferenceField}]"; + label = $"ForeignKey: {foreignKey.ReferenceEnumType.Name}.{foreignKey.ReferenceField}"; + break; default: return null; } + + var validation = (CsvValidationAttribute)attribute; + string conditionGroup = validation.ConditionGroup == 0 + ? string.Empty + : $", Group: {validation.ConditionGroup}"; + return $"[{label}{conditionGroup}]"; } private static CsvValidationResult ValidateDocument(CsvDocument document) diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs new file mode 100644 index 0000000..ea921eb --- /dev/null +++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs @@ -0,0 +1,232 @@ +using System; + +namespace CSV4Unity.Validation +{ + /// + /// コンパイル済みの行条件を評価します。 + /// + internal static class CsvConditionEvaluator + { + public static bool Matches( + CsvTable table, + int rowIndex, + CsvConditionRule[] conditions, + IFormatProvider formatProvider) + where TField : struct, Enum + { + for (int i = 0; i < conditions.Length; i++) + { + if (!Matches(table, rowIndex, conditions[i], formatProvider)) return false; + } + + return true; + } + + private static bool Matches( + CsvTable table, + int rowIndex, + CsvConditionRule condition, + IFormatProvider formatProvider) + where TField : struct, Enum + { + CsvCell cell = table.Cell(rowIndex, condition.Field); + switch (condition.Comparison) + { + case Compare.IsEmpty: + return cell.IsEmpty; + case Compare.IsNotEmpty: + return !cell.IsEmpty; + case Compare.In: + return MatchesAny(table, rowIndex, cell, condition, formatProvider); + case Compare.NotIn: + return !MatchesAny(table, rowIndex, cell, condition, formatProvider); + default: + return CompareCell( + table, + rowIndex, + cell, + condition.Values[0], + condition.Comparison, + condition.IgnoreCase, + formatProvider); + } + } + + private static bool MatchesAny( + CsvTable table, + int rowIndex, + CsvCell cell, + CsvConditionRule condition, + IFormatProvider formatProvider) + where TField : struct, Enum + { + for (int i = 0; i < condition.Values.Length; i++) + { + if (CompareCell( + table, + rowIndex, + cell, + condition.Values[i], + Compare.Equal, + condition.IgnoreCase, + formatProvider)) + { + return true; + } + } + + return false; + } + + private static bool CompareCell( + CsvTable table, + int rowIndex, + CsvCell left, + object rightOperand, + Compare comparison, + bool ignoreCase, + IFormatProvider formatProvider) + where TField : struct, Enum + { + if (rightOperand is TField rightField) + { + return CompareCells( + left, + table.Cell(rowIndex, rightField), + comparison, + ignoreCase, + formatProvider); + } + + if (rightOperand != null && IsNumericType(rightOperand.GetType())) + { + if (!left.TryGet(out double leftNumber, formatProvider)) + { + return comparison == Compare.NotEqual; + } + + double rightNumber = Convert.ToDouble(rightOperand, formatProvider); + return CompareNumbers(leftNumber, rightNumber, comparison); + } + + if (rightOperand is bool rightBoolean) + { + if (!left.TryGet(out bool leftBoolean, formatProvider)) + { + return comparison == Compare.NotEqual; + } + + return CompareNumbers(leftBoolean ? 1 : 0, rightBoolean ? 1 : 0, comparison); + } + + string rightText = Convert.ToString(rightOperand, formatProvider) ?? string.Empty; + return CompareCellText(left, rightText, comparison, ignoreCase); + } + + private static bool CompareCells( + CsvCell left, + CsvCell right, + Compare comparison, + bool ignoreCase, + IFormatProvider formatProvider) + { + if (comparison == Compare.Equal || comparison == Compare.NotEqual) + { + return CompareCellTexts(left, right, comparison, ignoreCase); + } + + if (!left.TryGet(out double leftNumber, formatProvider) || + !right.TryGet(out double rightNumber, formatProvider)) + { + return false; + } + + return CompareNumbers(leftNumber, rightNumber, comparison); + } + + private static bool CompareCellText( + CsvCell left, + string right, + Compare comparison, + bool ignoreCase) + { + StringComparison stringComparison = ignoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!left.HasEscapedQuotes) + { + int spanResult = left.RawSpan.CompareTo(right.AsSpan(), stringComparison); + return CompareResult(spanResult, comparison); + } + + int stringResult = string.Compare(left.GetString(), right, stringComparison); + return CompareResult(stringResult, comparison); + } + + private static bool CompareCellTexts( + CsvCell left, + CsvCell right, + Compare comparison, + bool ignoreCase) + { + StringComparison stringComparison = ignoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!left.HasEscapedQuotes && !right.HasEscapedQuotes) + { + int spanResult = left.RawSpan.CompareTo(right.RawSpan, stringComparison); + return CompareResult(spanResult, comparison); + } + + int stringResult = string.Compare(left.GetString(), right.GetString(), stringComparison); + return CompareResult(stringResult, comparison); + } + + private static bool CompareNumbers(double left, double right, Compare comparison) + { + return CompareResult(left.CompareTo(right), comparison); + } + + private static bool CompareResult(int result, Compare comparison) + { + switch (comparison) + { + case Compare.Equal: + return result == 0; + case Compare.NotEqual: + return result != 0; + case Compare.GreaterThan: + return result > 0; + case Compare.GreaterThanOrEqual: + return result >= 0; + case Compare.LessThan: + return result < 0; + case Compare.LessThanOrEqual: + return result <= 0; + default: + return false; + } + } + + private static bool IsNumericType(Type type) + { + switch (Type.GetTypeCode(type)) + { + case TypeCode.SByte: + case TypeCode.Byte: + case TypeCode.Int16: + case TypeCode.UInt16: + case TypeCode.Int32: + case TypeCode.UInt32: + case TypeCode.Int64: + case TypeCode.UInt64: + case TypeCode.Single: + case TypeCode.Double: + case TypeCode.Decimal: + return true; + default: + return false; + } + } + } +} diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs.meta b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs.meta new file mode 100644 index 0000000..b9327f6 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e364276765b04cfca6bc95bca756cd58 diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs index d028459..f12fbc2 100644 --- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs +++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs @@ -2,12 +2,109 @@ namespace CSV4Unity.Validation { + /// + /// 条件付きValidationで使用する比較方法を表します。 + /// + public enum Compare + { + /// 値が等しいことを判定します。 + Equal, + + /// 値が等しくないことを判定します。 + NotEqual, + + /// 値が比較対象より大きいことを判定します。 + GreaterThan, + + /// 値が比較対象以上であることを判定します。 + GreaterThanOrEqual, + + /// 値が比較対象より小さいことを判定します。 + LessThan, + + /// 値が比較対象以下であることを判定します。 + LessThanOrEqual, + + /// セルが空であることを判定します。 + IsEmpty, + + /// セルが空でないことを判定します。 + IsNotEmpty, + + /// 値が候補のいずれかと等しいことを判定します。 + In, + + /// 値がすべての候補と異なることを判定します。 + NotIn + } + + /// + /// Validation属性に適用する行条件を定義します。 + /// + /// + /// 同じグループに属する条件はANDとして評価されます。条件値に同じEnum型の値を指定すると、 + /// リテラルではなく同じ行の別列を参照します。数値として比較する場合は文字列ではなく数値リテラルを指定してください。 + /// 条件不成立はエラーではなく、対応するValidation属性をその行で実行しないことを意味します。 + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public sealed class ConditionAttribute : Attribute + { + /// 条件を適用するグループ番号を取得します。 + public int Group { get; } + + /// 条件判定に使用するEnumフィールドを取得します。 + public object Field { get; } + + /// 比較方法を取得します。 + public Compare Comparison { get; } + + /// 比較する値またはEnumフィールドを取得します。 + public object[] Values { get; } + + /// 文字列比較で大文字小文字を無視するかを取得または設定します。 + public bool IgnoreCase { get; set; } + + /// グループ0に行条件を定義します。 + /// 条件判定に使用するEnumフィールド。 + /// 比較方法。 + /// 比較する値。IsEmptyとIsNotEmptyでは省略します。 + public ConditionAttribute(object field, Compare comparison, params object[] values) + : this(0, field, comparison, values) + { + } + + /// 指定グループに行条件を定義します。 + /// 0以上のグループ番号。 + /// 条件判定に使用するEnumフィールド。 + /// 比較方法。 + /// 比較する値。IsEmptyとIsNotEmptyでは省略します。 + public ConditionAttribute(int group, object field, Compare comparison, params object[] values) + { + Group = group; + Field = field; + Comparison = comparison; + Values = values ?? Array.Empty(); + } + } + + /// + /// Validation属性に共通する条件グループを提供します。 + /// + public abstract class CsvValidationAttribute : Attribute + { + /// + /// 適用条件のグループ番号を取得または設定します。既定値は0で、同じフィールドのConditionと対応します。 + /// + /// 対応するConditionがないグループ0は無条件です。1以上の未定義グループはスキーマエラーになります。 + public int ConditionGroup { get; set; } + } + /// /// 列の各値が空でなく、一意であることを要求します。 /// /// 値はデコード済み文字列として、大文字小文字を区別して比較されます。 - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class PrimaryKeyAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class PrimaryKeyAttribute : CsvValidationAttribute { } @@ -15,16 +112,16 @@ public class PrimaryKeyAttribute : Attribute /// セルが空でないことを要求します。 /// /// 空文字列を未入力として扱います。空白だけの文字列は空とはみなしません。 - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class NotNullAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class NotNullAttribute : CsvValidationAttribute { } /// /// セルを指定型へ変換できることを要求します。 /// - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class TypeConstraintAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class TypeConstraintAttribute : CsvValidationAttribute { /// 要求する変換先型を取得します。 public Type ExpectedType { get; } @@ -43,8 +140,8 @@ public TypeConstraintAttribute(Type expectedType) /// 数値が指定範囲内にあることを要求します。 /// /// 最小値と最大値を含む範囲として、検証時の形式プロバイダーを使ってdoubleへ変換します。 - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class RangeAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class RangeAttribute : CsvValidationAttribute { /// 許可する最小値を取得します。 public double Min { get; } @@ -68,8 +165,8 @@ public RangeAttribute(double min, double max) /// 空でないセルの値が一意であることを要求します。 /// /// 空セルは検証対象から除外します。値は大文字小文字を区別して比較されます。 - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class UniqueAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class UniqueAttribute : CsvValidationAttribute { } @@ -77,8 +174,8 @@ public class UniqueAttribute : Attribute /// セル文字列が指定した正規表現に一致することを要求します。 /// /// 正規表現はで生成されます。 - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class RegexAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class RegexAttribute : CsvValidationAttribute { /// 正規表現パターンを取得します。 public string Pattern { get; } @@ -97,8 +194,8 @@ public RegexAttribute(string pattern) /// セル文字列が許可値のいずれかと一致することを要求します。 /// /// 許可値はInvariantCultureで文字列化し、大文字小文字を区別して比較されます。 - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class AllowedValuesAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class AllowedValuesAttribute : CsvValidationAttribute { /// 指定された許可値を取得します。 public object[] AllowedValues { get; } @@ -120,8 +217,8 @@ public AllowedValuesAttribute(params object[] allowedValues) /// 同じEnum型を指定した場合は検証対象テーブル内を参照します。別のEnum型を指定する場合は、 /// で参照先を登録します。 /// - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class ForeignKeyAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class ForeignKeyAttribute : CsvValidationAttribute { /// 参照先テーブルを識別するEnum型を取得します。 public Type ReferenceEnumType { get; } @@ -144,8 +241,8 @@ public ForeignKeyAttribute(Type referenceEnumType, string referenceField) /// /// セル文字列が指定した最小長以上であることを要求します。 /// - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class MinLengthAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class MinLengthAttribute : CsvValidationAttribute { /// で判定する最小長を取得します。 public int MinLength { get; } @@ -161,8 +258,8 @@ public MinLengthAttribute(int minLength) /// /// セル文字列が指定した最大長以下であることを要求します。 /// - [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] - public class MaxLengthAttribute : Attribute + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public class MaxLengthAttribute : CsvValidationAttribute { /// で判定する最大長を取得します。 public int MaxLength { get; } diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs index 23985f7..7feaab3 100644 --- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs +++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs @@ -7,7 +7,7 @@ namespace CSV4Unity.Validation { /// - /// Enumフィールドの制約属性を、再利用可能な検証規則へ変換します。 + /// Enumフィールドの制約属性と行条件を、再利用可能な検証規則へ変換します。 /// /// 制約属性を定義したEnum型。 /// 生成後の規則は変更されず、複数ののValidationに再利用できます。 @@ -23,12 +23,13 @@ private CsvValidationSchema(CsvFieldValidationRule[] rules) /// Enum型ごとに一度生成される既定スキーマを取得します。 public static CsvValidationSchema Default { get; } = Create(); - /// 1つ以上の制約属性を持つEnumフィールド数を取得します。 + /// Enumに定義されたValidation属性の総数を取得します。 public int RuleCount => _rules.Length; - /// Enumに定義された制約属性からスキーマを作成します。 + /// Enumに定義された制約属性と行条件からスキーマを作成します。 /// 属性をコンパイルした新しいValidationスキーマ。 /// 定義された正規表現パターンが不正です。 + /// Conditionのフィールドまたはグループ定義が不正です。 /// /// Reflectionによる属性読み取りと正規表現の生成を行います。繰り返し検証する場合はを再利用してください。 /// 制約属性を持たないEnumフィールドは規則に含まれません。 @@ -40,10 +41,7 @@ public static CsvValidationSchema Create() for (int i = 0; i < fields.Length; i++) { - FieldInfo fieldInfo = fields[i]; - object[] attributes = fieldInfo.GetCustomAttributes(typeof(Attribute), false); - CsvFieldValidationRule rule = CreateRule(fieldInfo, attributes); - if (rule != null) rules.Add(rule); + CreateRules(fields[i], rules); } return new CsvValidationSchema(rules.ToArray()); @@ -51,64 +49,226 @@ public static CsvValidationSchema Create() internal IReadOnlyList> Rules => _rules; - private static CsvFieldValidationRule CreateRule(FieldInfo fieldInfo, object[] attributes) + private static void CreateRules( + FieldInfo fieldInfo, + List> destination) + { + object[] attributes = fieldInfo.GetCustomAttributes(typeof(Attribute), false); + var conditionsByGroup = new Dictionary>>(); + var validations = new List(); + + for (int i = 0; i < attributes.Length; i++) + { + if (attributes[i] is ConditionAttribute condition) + { + CsvConditionRule compiledCondition = CreateCondition(fieldInfo, condition); + if (!conditionsByGroup.TryGetValue(condition.Group, out List> group)) + { + group = new List>(); + conditionsByGroup.Add(condition.Group, group); + } + + group.Add(compiledCondition); + } + else if (attributes[i] is CsvValidationAttribute validation) + { + if (validation.ConditionGroup < 0) + { + throw new CsvSchemaException( + $"Validation attribute on '{typeof(TField).Name}.{fieldInfo.Name}' has a negative condition group."); + } + + validations.Add(validation); + } + } + + var usedGroups = new HashSet(); + var primaryKeyGroups = new HashSet(); + var typeConstraintGroups = new HashSet(); + for (int i = 0; i < validations.Count; i++) + { + if (validations[i] is PrimaryKeyAttribute) + { + primaryKeyGroups.Add(validations[i].ConditionGroup); + } + else if (validations[i] is TypeConstraintAttribute) + { + typeConstraintGroups.Add(validations[i].ConditionGroup); + } + } + + for (int i = 0; i < validations.Count; i++) + { + CsvValidationAttribute validation = validations[i]; + CsvConditionRule[] conditions = Array.Empty>(); + if (conditionsByGroup.TryGetValue( + validation.ConditionGroup, + out List> conditionList)) + { + conditions = conditionList.ToArray(); + usedGroups.Add(validation.ConditionGroup); + } + else if (validation.ConditionGroup != 0) + { + throw new CsvSchemaException( + $"Validation attribute on '{typeof(TField).Name}.{fieldInfo.Name}' references undefined condition group {validation.ConditionGroup}."); + } + + destination.Add(CreateRule( + fieldInfo, + validation, + conditions, + primaryKeyGroups.Contains(validation.ConditionGroup), + typeConstraintGroups.Contains(validation.ConditionGroup))); + } + + foreach (int group in conditionsByGroup.Keys) + { + if (!usedGroups.Contains(group)) + { + throw new CsvSchemaException( + $"Condition group {group} on '{typeof(TField).Name}.{fieldInfo.Name}' is not used by a validation attribute."); + } + } + } + + private static CsvConditionRule CreateCondition( + FieldInfo targetField, + ConditionAttribute condition) + { + if (condition.Group < 0) + { + throw new CsvSchemaException( + $"Condition on '{typeof(TField).Name}.{targetField.Name}' has a negative group number."); + } + + if (!Enum.IsDefined(typeof(Compare), condition.Comparison)) + { + throw new CsvSchemaException( + $"Condition on '{typeof(TField).Name}.{targetField.Name}' uses an unsupported comparison value."); + } + + ValidateConditionValueCount(targetField, condition); + + if (!(condition.Field is TField conditionField) || !Enum.IsDefined(typeof(TField), conditionField)) + { + throw new CsvSchemaException( + $"Condition on '{typeof(TField).Name}.{targetField.Name}' must reference a field from enum '{typeof(TField).Name}'."); + } + + object[] values = new object[condition.Values.Length]; + for (int i = 0; i < condition.Values.Length; i++) + { + object value = condition.Values[i]; + if (value is TField referencedField && !Enum.IsDefined(typeof(TField), referencedField)) + { + throw new CsvSchemaException( + $"Condition on '{typeof(TField).Name}.{targetField.Name}' references an undefined enum value."); + } + + values[i] = value; + } + + return new CsvConditionRule + { + Field = conditionField, + Comparison = condition.Comparison, + Values = values, + IgnoreCase = condition.IgnoreCase + }; + } + + private static void ValidateConditionValueCount( + FieldInfo targetField, + ConditionAttribute condition) + { + int count = condition.Values.Length; + bool valid; + switch (condition.Comparison) + { + case Compare.IsEmpty: + case Compare.IsNotEmpty: + valid = count == 0; + break; + case Compare.In: + case Compare.NotIn: + valid = count > 0; + break; + default: + valid = count == 1; + break; + } + + if (!valid) + { + throw new CsvSchemaException( + $"Condition '{condition.Comparison}' on '{typeof(TField).Name}.{targetField.Name}' has an invalid number of comparison values."); + } + } + + private static CsvFieldValidationRule CreateRule( + FieldInfo fieldInfo, + CsvValidationAttribute validation, + CsvConditionRule[] conditions, + bool hasPrimaryKey, + bool hasTypeConstraint) { var rule = new CsvFieldValidationRule { Field = (TField)fieldInfo.GetValue(null), - FieldName = fieldInfo.Name + FieldName = fieldInfo.Name, + ConditionGroup = validation.ConditionGroup, + Conditions = conditions, + SuppressRequiredError = validation is NotNullAttribute && hasPrimaryKey, + SuppressNumericError = validation is RangeAttribute && hasTypeConstraint }; - bool hasConstraint = false; - for (int i = 0; i < attributes.Length; i++) + switch (validation) { - switch (attributes[i]) - { - case PrimaryKeyAttribute: - rule.IsPrimaryKey = true; - hasConstraint = true; - break; - case NotNullAttribute: - rule.IsRequired = true; - hasConstraint = true; - break; - case UniqueAttribute: - rule.IsUnique = true; - hasConstraint = true; - break; - case TypeConstraintAttribute typeConstraint: - rule.ExpectedType = typeConstraint.ExpectedType; - hasConstraint = true; - break; - case RangeAttribute range: - rule.RangeMin = range.Min; - rule.RangeMax = range.Max; - hasConstraint = true; - break; - case RegexAttribute regex: - rule.Pattern = new Regex(regex.Pattern, RegexOptions.CultureInvariant); - hasConstraint = true; - break; - case AllowedValuesAttribute allowedValues: - rule.AllowedValues = CreateAllowedValues(allowedValues.AllowedValues); - hasConstraint = true; - break; - case MinLengthAttribute minLength: - rule.MinLength = minLength.MinLength; - hasConstraint = true; - break; - case MaxLengthAttribute maxLength: - rule.MaxLength = maxLength.MaxLength; - hasConstraint = true; - break; - case ForeignKeyAttribute foreignKey: - rule.ForeignKey = foreignKey; - hasConstraint = true; - break; - } + case PrimaryKeyAttribute: + rule.Kind = CsvValidationRuleKind.PrimaryKey; + break; + case NotNullAttribute: + rule.Kind = CsvValidationRuleKind.NotNull; + break; + case UniqueAttribute: + rule.Kind = CsvValidationRuleKind.Unique; + break; + case TypeConstraintAttribute typeConstraint: + rule.Kind = CsvValidationRuleKind.TypeConstraint; + rule.ExpectedType = typeConstraint.ExpectedType; + break; + case RangeAttribute range: + rule.Kind = CsvValidationRuleKind.Range; + rule.RangeMin = range.Min; + rule.RangeMax = range.Max; + break; + case RegexAttribute regex: + rule.Kind = CsvValidationRuleKind.Regex; + rule.Pattern = new Regex(regex.Pattern, RegexOptions.CultureInvariant); + break; + case AllowedValuesAttribute allowedValues: + rule.Kind = CsvValidationRuleKind.AllowedValues; + rule.AllowedValues = CreateAllowedValues(allowedValues.AllowedValues); + break; + case MinLengthAttribute minLength: + rule.Kind = CsvValidationRuleKind.MinLength; + rule.MinLength = minLength.MinLength; + break; + case MaxLengthAttribute maxLength: + rule.Kind = CsvValidationRuleKind.MaxLength; + rule.MaxLength = maxLength.MaxLength; + break; + case ForeignKeyAttribute foreignKey: + rule.Kind = CsvValidationRuleKind.ForeignKey; + rule.ForeignKey = foreignKey; + break; + default: + throw new CsvSchemaException( + $"Unsupported validation attribute '{validation.GetType().FullName}'."); } - return hasConstraint ? rule : null; + return rule; } private static HashSet CreateAllowedValues(object[] values) @@ -123,20 +283,44 @@ private static HashSet CreateAllowedValues(object[] values) } } + internal enum CsvValidationRuleKind + { + PrimaryKey, + NotNull, + TypeConstraint, + Range, + Unique, + Regex, + AllowedValues, + MinLength, + MaxLength, + ForeignKey + } + internal sealed class CsvFieldValidationRule where TField : struct, Enum { public TField Field { get; set; } public string FieldName { get; set; } - public bool IsPrimaryKey { get; set; } - public bool IsRequired { get; set; } - public bool IsUnique { get; set; } + public int ConditionGroup { get; set; } + public CsvConditionRule[] Conditions { get; set; } + public CsvValidationRuleKind Kind { get; set; } public Type ExpectedType { get; set; } - public double? RangeMin { get; set; } - public double? RangeMax { get; set; } + public double RangeMin { get; set; } + public double RangeMax { get; set; } public Regex Pattern { get; set; } public HashSet AllowedValues { get; set; } - public int? MinLength { get; set; } - public int? MaxLength { get; set; } + public int MinLength { get; set; } + public int MaxLength { get; set; } public ForeignKeyAttribute ForeignKey { get; set; } + public bool SuppressRequiredError { get; set; } + public bool SuppressNumericError { get; set; } + } + + internal sealed class CsvConditionRule where TField : struct, Enum + { + public TField Field { get; set; } + public Compare Comparison { get; set; } + public object[] Values { get; set; } + public bool IgnoreCase { get; set; } } } diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidator.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidator.cs index 36e9348..b18617f 100644 --- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidator.cs +++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidator.cs @@ -11,7 +11,7 @@ namespace CSV4Unity.Validation public static class CsvValidator { /// - /// Enumの制約属性に従ってテーブルを検証します。読み込んだデータ自体は変更しません。 + /// Enumの制約属性と行条件に従ってテーブルを検証します。読み込んだデータ自体は変更しません。 /// /// 列と制約属性を定義したEnum型。 /// 検証するEnum対応テーブル。 @@ -19,13 +19,12 @@ public static class CsvValidator /// 使用するValidationスキーマ。の場合はを使用します。 /// /// ForeignKeyの参照先テーブル。参照先が同じテーブルだけの場合はにできます。 - /// 型変換と数値範囲検証に使用する形式。の場合はを使用します。 + /// 型変換、数値比較、数値範囲検証に使用する形式。の場合はを使用します。 /// すべてのエラーとWarningを格納したValidation結果。 /// です。 /// - /// PrimaryKeyは空でなく一意、Uniqueは空セルを除いて一意であることを大文字小文字を区別して検証します。 - /// ForeignKeyの参照テーブルまたは列を解決できない場合、その制約を実行せずWarningを追加します。 - /// 空セルはPrimaryKeyとNotNullを除くセル単位制約の対象外です。 + /// 同じConditionグループの条件はANDとして評価され、条件が成立した行だけ対応するValidation属性を適用します。 + /// 条件を持たない属性は従来どおりすべての行へ適用されます。 /// public static CsvValidationResult Validate( CsvTable table, @@ -43,13 +42,13 @@ public static CsvValidationResult Validate( IReadOnlyList> rules = schema.Rules; for (int i = 0; i < rules.Count; i++) { - ValidateField(table, rules[i], context, provider, result); + ValidateRule(table, rules[i], context, provider, result); } return result; } - private static void ValidateField( + private static void ValidateRule( CsvTable table, CsvFieldValidationRule rule, CsvValidationContext context, @@ -59,29 +58,38 @@ private static void ValidateField( { CsvColumn column = table.Column(rule.Field); - // 列全体の制約は行ループの外で一度だけ検証する。 - if (rule.IsPrimaryKey) + if (rule.Kind == CsvValidationRuleKind.PrimaryKey) { - ValidateDistinct(column, rule.FieldName, true, result); + ValidateDistinct(table, column, rule, true, formatProvider, result); + return; } - else if (rule.IsUnique) + + if (rule.Kind == CsvValidationRuleKind.Unique) { - ValidateDistinct(column, rule.FieldName, false, result); + ValidateDistinct(table, column, rule, false, formatProvider, result); + return; } - HashSet referenceValues = PrepareForeignKeyValues(table, rule, context, result); + HashSet referenceValues = rule.Kind == CsvValidationRuleKind.ForeignKey + ? PrepareForeignKeyValues(table, rule, context, result) + : null; for (int rowIndex = 0; rowIndex < column.Count; rowIndex++) { - CsvCell cell = column[rowIndex]; + if (!CsvConditionEvaluator.Matches(table, rowIndex, rule.Conditions, formatProvider)) continue; - if (rule.IsRequired && !rule.IsPrimaryKey && cell.IsEmpty) + CsvCell cell = column[rowIndex]; + if (rule.Kind == CsvValidationRuleKind.NotNull) { - result.AddError(rowIndex, rule.FieldName, "Value cannot be empty."); + if (cell.IsEmpty && !rule.SuppressRequiredError) + { + result.AddError(rowIndex, rule.FieldName, "Value cannot be empty."); + } + + continue; } if (cell.IsEmpty) continue; - ValidateCell(cell, rowIndex, rule, formatProvider, referenceValues, result); } } @@ -95,74 +103,107 @@ private static void ValidateCell( CsvValidationResult result) where TField : struct, Enum { - if (rule.ExpectedType != null && !cell.CanGet(rule.ExpectedType, formatProvider)) + switch (rule.Kind) { - result.AddError( - rowIndex, - rule.FieldName, - $"Value '{cell.GetString()}' cannot be converted to {rule.ExpectedType.Name}."); - } + case CsvValidationRuleKind.TypeConstraint: + if (!cell.CanGet(rule.ExpectedType, formatProvider)) + { + result.AddError( + rowIndex, + rule.FieldName, + $"Value '{cell.GetString()}' cannot be converted to {rule.ExpectedType.Name}."); + } - if (rule.RangeMin.HasValue) - { - if (!cell.TryGet(out double value, formatProvider)) + break; + + case CsvValidationRuleKind.Range: + if (!cell.TryGet(out double value, formatProvider)) + { + if (!rule.SuppressNumericError) + { + result.AddError(rowIndex, rule.FieldName, "Range validation requires a numeric value."); + } + } + else if (value < rule.RangeMin || value > rule.RangeMax) + { + result.AddError( + rowIndex, + rule.FieldName, + $"Value {value} is outside the range [{rule.RangeMin}, {rule.RangeMax}]."); + } + + break; + + case CsvValidationRuleKind.Regex: { - if (rule.ExpectedType == null) + string text = cell.GetString(); + if (!rule.Pattern.IsMatch(text)) { - result.AddError(rowIndex, rule.FieldName, "Range validation requires a numeric value."); + result.AddError(rowIndex, rule.FieldName, $"Value '{text}' does not match the required pattern."); } + + break; } - else if (value < rule.RangeMin.Value || value > rule.RangeMax.Value) + + case CsvValidationRuleKind.AllowedValues: { - result.AddError( - rowIndex, - rule.FieldName, - $"Value {value} is outside the range [{rule.RangeMin.Value}, {rule.RangeMax.Value}]."); + string text = cell.GetString(); + if (!rule.AllowedValues.Contains(text)) + { + result.AddError(rowIndex, rule.FieldName, $"Value '{text}' is not allowed."); + } + + break; } - } - bool requiresString = rule.Pattern != null || rule.AllowedValues != null || - rule.MinLength.HasValue || rule.MaxLength.HasValue || - referenceValues != null; - if (!requiresString) return; + case CsvValidationRuleKind.MinLength: + { + int length = cell.GetString().Length; + if (length < rule.MinLength) + { + result.AddError( + rowIndex, + rule.FieldName, + $"Length {length} is less than the minimum {rule.MinLength}."); + } - string text = cell.GetString(); - if (rule.Pattern != null && !rule.Pattern.IsMatch(text)) - { - result.AddError(rowIndex, rule.FieldName, $"Value '{text}' does not match the required pattern."); - } + break; + } - if (rule.AllowedValues != null && !rule.AllowedValues.Contains(text)) - { - result.AddError(rowIndex, rule.FieldName, $"Value '{text}' is not allowed."); - } + case CsvValidationRuleKind.MaxLength: + { + int length = cell.GetString().Length; + if (length > rule.MaxLength) + { + result.AddError( + rowIndex, + rule.FieldName, + $"Length {length} exceeds the maximum {rule.MaxLength}."); + } - if (rule.MinLength.HasValue && text.Length < rule.MinLength.Value) - { - result.AddError( - rowIndex, - rule.FieldName, - $"Length {text.Length} is less than the minimum {rule.MinLength.Value}."); - } + break; + } - if (rule.MaxLength.HasValue && text.Length > rule.MaxLength.Value) - { - result.AddError( - rowIndex, - rule.FieldName, - $"Length {text.Length} exceeds the maximum {rule.MaxLength.Value}."); - } + case CsvValidationRuleKind.ForeignKey: + if (referenceValues != null) + { + string text = cell.GetString(); + if (!referenceValues.Contains(text)) + { + result.AddError(rowIndex, rule.FieldName, $"Referenced value '{text}' was not found."); + } + } - if (referenceValues != null && !referenceValues.Contains(text)) - { - result.AddError(rowIndex, rule.FieldName, $"Referenced value '{text}' was not found."); + break; } } private static void ValidateDistinct( + CsvTable table, CsvColumn column, - string fieldName, + CsvFieldValidationRule rule, bool requireValue, + IFormatProvider formatProvider, CsvValidationResult result) where TField : struct, Enum { @@ -170,12 +211,14 @@ private static void ValidateDistinct( for (int rowIndex = 0; rowIndex < column.Count; rowIndex++) { + if (!CsvConditionEvaluator.Matches(table, rowIndex, rule.Conditions, formatProvider)) continue; + CsvCell cell = column[rowIndex]; if (cell.IsEmpty) { if (requireValue) { - result.AddError(rowIndex, fieldName, "Primary key cannot be empty."); + result.AddError(rowIndex, rule.FieldName, "Primary key cannot be empty."); } continue; @@ -185,7 +228,7 @@ private static void ValidateDistinct( if (!seen.Add(value)) { string constraint = requireValue ? "primary key" : "unique"; - result.AddError(rowIndex, fieldName, $"Duplicate {constraint} value: '{value}'."); + result.AddError(rowIndex, rule.FieldName, $"Duplicate {constraint} value: '{value}'."); } } } @@ -197,8 +240,6 @@ private static HashSet PrepareForeignKeyValues( CsvValidationResult result) where TField : struct, Enum { - if (rule.ForeignKey == null) return null; - CsvColumn referenceColumn; if (rule.ForeignKey.ReferenceEnumType == typeof(TField)) { diff --git a/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaException.cs b/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaException.cs index a40ad56..a66ff86 100644 --- a/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaException.cs +++ b/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaException.cs @@ -3,7 +3,7 @@ namespace CSV4Unity { /// - /// CSVヘッダーとEnumスキーマを対応付けられない場合に送出される例外です。 + /// CSVヘッダー、Enumフィールド、またはValidation属性から有効なスキーマを構築できない場合に送出される例外です。 /// public sealed class CsvSchemaException : InvalidOperationException { From a7cb509a64d13b2e8968ac213180244ff6870ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:31:55 +0900 Subject: [PATCH 05/28] =?UTF-8?q?test:=20=E6=9D=A1=E4=BB=B6=E4=BB=98?= =?UTF-8?q?=E3=81=8D=E3=83=90=E3=83=AA=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E6=A4=9C=E8=A8=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Scenes/CSVLoaderSample.unity | 1 + .../Tests/EditMode/CsvValidationTableTests.cs | 252 ++++++++++++++++++ .../Tests/Manual/CsvCoreManualCheck.cs | 18 ++ .../Tests/Manual/CsvManualCheckFields.cs | 22 ++ .../CSV4Unity/ConditionalValidation.csv | 9 + .../CSV4Unity/ConditionalValidation.csv.meta | 7 + 6 files changed, 309 insertions(+) create mode 100644 Assets/TestData/CSV4Unity/ConditionalValidation.csv create mode 100644 Assets/TestData/CSV4Unity/ConditionalValidation.csv.meta diff --git a/Assets/Scenes/CSVLoaderSample.unity b/Assets/Scenes/CSVLoaderSample.unity index 37d26b3..b072fc0 100644 --- a/Assets/Scenes/CSVLoaderSample.unity +++ b/Assets/Scenes/CSVLoaderSample.unity @@ -319,6 +319,7 @@ MonoBehaviour: scenarioCsv: {fileID: 4900000, guid: 0eda03e41d068ec4183071b6fe0cff83, type: 3} rfc4180Csv: {fileID: 4900000, guid: f66e0520e7494d7480f7a8ccc03d79d8, type: 3} invalidValidationCsv: {fileID: 4900000, guid: e75038cd7b06493b9628c76b6435892c, type: 3} + conditionalValidationCsv: {fileID: 4900000, guid: d1079a119b074fd69376bfca105b6a30, type: 3} hugeDataCsv: {fileID: 4900000, guid: 939f9877eaf38664893456a19a4db82f, type: 3} headerMappingCsv: {fileID: 4900000, guid: 3e1b4ae2b01148cb9ff03a7e6b8921aa, type: 3} runOnStart: 1 diff --git a/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs b/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs index 4b7d618..433eb9d 100644 --- a/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs +++ b/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs @@ -58,6 +58,154 @@ private enum FixtureValidationField Level } + private enum ConditionalField + { + Command, + Enabled, + + [Condition(ConditionalField.Command, Compare.Equal, "A")] + [Condition(ConditionalField.Enabled, Compare.Equal, true)] + [NotNull] + [TypeConstraint(typeof(int))] + Arg + } + + private enum BranchField + { + Command, + + [Condition(1, BranchField.Command, Compare.Equal, "A")] + [NotNull(ConditionGroup = 1)] + [TypeConstraint(typeof(int), ConditionGroup = 1)] + + [Condition(2, BranchField.Command, Compare.Equal, "B")] + [NotNull(ConditionGroup = 2)] + [TypeConstraint(typeof(bool), ConditionGroup = 2)] + + [Condition(3, BranchField.Command, Compare.NotIn, "A", "B")] + [AllowedValues("fallback", ConditionGroup = 3)] + Arg + } + + private enum CompareField + { + Numeric, + OtherNumeric, + Text, + EmptySource, + + [Condition(CompareField.Numeric, Compare.Equal, 10)] + [NotNull] + Equal, + + [Condition(CompareField.Numeric, Compare.NotEqual, 11)] + [NotNull] + NotEqual, + + [Condition(CompareField.Numeric, Compare.GreaterThan, 9)] + [NotNull] + GreaterThan, + + [Condition(CompareField.Numeric, Compare.GreaterThanOrEqual, 10)] + [NotNull] + GreaterThanOrEqual, + + [Condition(CompareField.Numeric, Compare.LessThan, 11)] + [NotNull] + LessThan, + + [Condition(CompareField.Numeric, Compare.LessThanOrEqual, 10)] + [NotNull] + LessThanOrEqual, + + [Condition(CompareField.EmptySource, Compare.IsEmpty)] + [NotNull] + IsEmpty, + + [Condition(CompareField.Text, Compare.IsNotEmpty)] + [NotNull] + IsNotEmpty, + + [Condition(CompareField.Text, Compare.In, "Beta", "Alpha")] + [NotNull] + In, + + [Condition(CompareField.Text, Compare.NotIn, "Beta", "Gamma")] + [NotNull] + NotIn, + + [Condition(CompareField.Text, Compare.Equal, "alpha", IgnoreCase = true)] + [NotNull] + IgnoreCase, + + [Condition(CompareField.Numeric, Compare.GreaterThan, CompareField.OtherNumeric)] + [NotNull] + ColumnComparison + } + + private enum OtherField + { + Value + } + + private enum InvalidConditionField + { + Source, + + [Condition(OtherField.Value, Compare.Equal, "A")] + [NotNull] + Target + } + + private enum UndefinedGroupField + { + Source, + + [Condition(1, UndefinedGroupField.Source, Compare.Equal, "A")] + [NotNull(ConditionGroup = 2)] + Target + } + + private enum InvalidConditionValueCountField + { + Source, + + [Condition(InvalidConditionValueCountField.Source, Compare.Equal)] + [NotNull] + Target + } + + private enum InvalidCompareField + { + Source, + + [Condition(InvalidCompareField.Source, (Compare)999, "A")] + [NotNull] + Target + } + + private enum ConditionalConstraintField + { + Mode, + + [Condition(ConditionalConstraintField.Mode, Compare.Equal, "A")] + [CSV4Unity.Validation.Range(1, 3)] + Number, + + [Condition(ConditionalConstraintField.Mode, Compare.Equal, "A")] + [Regex(@"^TAG-\d+$")] + Code, + + [Condition(ConditionalConstraintField.Mode, Compare.Equal, "A")] + [MinLength(2)] + [MaxLength(5)] + Name, + + [Condition(ConditionalConstraintField.Mode, Compare.Equal, "A")] + [Unique] + Key + } + [Test] public void Validate_ValidTable_ReturnsNoIssues() { @@ -104,6 +252,110 @@ public void Validate_InvalidFixture_ReportsExpectedErrors() Assert.That(result.Errors.Count, Is.EqualTo(6)); } + [Test] + public void Validate_MultipleConditions_AppliesRulesOnlyWhenAllConditionsMatch() + { + const string csv = + "Command,Enabled,Arg\n" + + "A,true,\n" + + "A,false,invalid\n" + + "B,true,invalid\n" + + "A,true,invalid\n" + + "A,true,10"; + CsvTable table = CsvParser.Parse(csv).WithFields(); + + CsvValidationResult result = CsvValidator.Validate(table); + + Assert.That(result.Errors.Count, Is.EqualTo(2)); + Assert.That(result.Errors[0].Row, Is.EqualTo(0)); + Assert.That(result.Errors[1].Row, Is.EqualTo(3)); + Assert.That(result.Errors.All(error => error.Column == nameof(ConditionalField.Arg)), Is.True); + } + + [Test] + public void Validate_ConditionGroups_SupportsCommandSpecificTypesAndFallback() + { + const string csv = + "Command,Arg\n" + + "A,\n" + + "A,invalid\n" + + "A,10\n" + + "B,invalid\n" + + "B,true\n" + + "C,invalid\n" + + "C,fallback"; + CsvTable table = CsvParser.Parse(csv).WithFields(); + + CsvValidationResult result = CsvValidator.Validate(table); + + Assert.That(result.Errors.Count, Is.EqualTo(4)); + Assert.That( + result.Errors.Select(error => error.Row).OrderBy(row => row), + Is.EqualTo(new[] { 0, 1, 3, 5 })); + } + + [Test] + public void Validate_CompareOperators_EvaluatesAllSupportedConditions() + { + const string header = + "Numeric,OtherNumeric,Text,EmptySource," + + "Equal,NotEqual,GreaterThan,GreaterThanOrEqual,LessThan,LessThanOrEqual," + + "IsEmpty,IsNotEmpty,In,NotIn,IgnoreCase,ColumnComparison"; + const string row = "10,5,Alpha,,,,,,,,,,,,,"; + CsvTable table = CsvParser.Parse(header + "\n" + row).WithFields(); + + CsvValidationResult result = CsvValidator.Validate(table); + + Assert.That(result.Errors.Count, Is.EqualTo(12)); + Assert.That(result.Errors.All(error => error.Row == 0), Is.True); + } + + [Test] + public void Validate_Conditions_ApplyToRangeTextAndUniqueConstraints() + { + const string csv = + "Mode,Number,Code,Name,Key\n" + + "A,5,bad,x,K1\n" + + "B,5,bad,x,K1\n" + + "A,2,TAG-1,Okay,K2\n" + + "A,2,TAG-2,VeryLong,K3\n" + + "A,2,TAG-3,Valid,K2"; + CsvTable table = CsvParser.Parse(csv) + .WithFields(); + + CsvValidationResult result = CsvValidator.Validate(table); + + Assert.That(result.Errors.Count, Is.EqualTo(5)); + Assert.That(result.Errors.Count(error => error.Row == 0), Is.EqualTo(3)); + Assert.That(result.Errors.Count(error => error.Row == 1), Is.EqualTo(0)); + Assert.That(result.Errors.Count(error => error.Column == nameof(ConditionalConstraintField.Key)), Is.EqualTo(1)); + } + + [Test] + public void CreateSchema_ConditionFromDifferentEnum_ThrowsSchemaException() + { + Assert.Throws(() => CsvValidationSchema.Create()); + } + + [Test] + public void CreateSchema_UndefinedConditionGroup_ThrowsSchemaException() + { + Assert.Throws(() => CsvValidationSchema.Create()); + } + + [Test] + public void CreateSchema_InvalidConditionValueCount_ThrowsSchemaException() + { + Assert.Throws( + () => CsvValidationSchema.Create()); + } + + [Test] + public void CreateSchema_UnknownComparison_ThrowsSchemaException() + { + Assert.Throws(() => CsvValidationSchema.Create()); + } + [Test] public void Validate_ForeignKey_UsesRegisteredReferenceTable() { diff --git a/Assets/Scripts/Tests/Manual/CsvCoreManualCheck.cs b/Assets/Scripts/Tests/Manual/CsvCoreManualCheck.cs index 257ad61..53ee404 100644 --- a/Assets/Scripts/Tests/Manual/CsvCoreManualCheck.cs +++ b/Assets/Scripts/Tests/Manual/CsvCoreManualCheck.cs @@ -23,6 +23,10 @@ public sealed class CsvCoreManualCheck : MonoBehaviour [Tooltip("Assets/TestData/CSV4Unity/ValidationInvalid.csv を指定してください")] private TextAsset invalidValidationCsv; + [SerializeField] + [Tooltip("Assets/TestData/CSV4Unity/ConditionalValidation.csv を指定してください")] + private TextAsset conditionalValidationCsv; + [SerializeField] [Tooltip("Assets/TestData/CSV4Unity/HugeData.csv を指定してください")] private TextAsset hugeDataCsv; @@ -50,6 +54,7 @@ public void RunChecks() CheckScenarioAccess(ref passed); CheckRfc4180(ref passed); CheckValidation(ref passed); + CheckConditionalValidation(ref passed); CheckLargeCsv(ref passed); CheckHeaderMapping(ref passed); @@ -108,6 +113,18 @@ private void CheckLargeCsv(ref int passed) passed += 2; } + private void CheckConditionalValidation(ref int passed) + { + CsvTable table = CSVLoader + .LoadTable(conditionalValidationCsv); + CsvValidationResult result = CsvValidator.Validate(table); + + Require(!result.IsValid, "ConditionalValidation.csvがValidとして扱われました。"); + Require(result.Errors.Count == 4, $"条件付きValidationの想定エラー数は4件ですが、実際は{result.Errors.Count}件です。"); + + passed += 2; + } + private void CheckHeaderMapping(ref int passed) { CsvTable table = CSVLoader.LoadTable(headerMappingCsv); @@ -124,6 +141,7 @@ private void EnsureFixturesAssigned() Require(scenarioCsv != null, "scenarioCsv に Scenario.csv を指定してください。"); Require(rfc4180Csv != null, "rfc4180Csv に Rfc4180.csv を指定してください。"); Require(invalidValidationCsv != null, "invalidValidationCsv に ValidationInvalid.csv を指定してください。"); + Require(conditionalValidationCsv != null, "conditionalValidationCsv に ConditionalValidation.csv を指定してください。"); Require(hugeDataCsv != null, "hugeDataCsv に HugeData.csv を指定してください。"); Require(headerMappingCsv != null, "headerMappingCsv に HeaderMapping.csv を指定してください。"); } diff --git a/Assets/Scripts/Tests/Manual/CsvManualCheckFields.cs b/Assets/Scripts/Tests/Manual/CsvManualCheckFields.cs index c2ff636..cb77929 100644 --- a/Assets/Scripts/Tests/Manual/CsvManualCheckFields.cs +++ b/Assets/Scripts/Tests/Manual/CsvManualCheckFields.cs @@ -44,4 +44,26 @@ public enum ManualValidationFields [CSV4Unity.Validation.Range(1, 10)] Level } + + /// + /// ConditionalValidation.csvのCommand別Validationに使用する手動確認用スキーマです。 + /// + public enum ConditionalValidationFields + { + Command, + Enabled, + + [Condition(1, ConditionalValidationFields.Command, Compare.Equal, "A")] + [Condition(1, ConditionalValidationFields.Enabled, Compare.Equal, true)] + [NotNull(ConditionGroup = 1)] + [TypeConstraint(typeof(int), ConditionGroup = 1)] + + [Condition(2, ConditionalValidationFields.Command, Compare.Equal, "B")] + [NotNull(ConditionGroup = 2)] + [TypeConstraint(typeof(bool), ConditionGroup = 2)] + + [Condition(3, ConditionalValidationFields.Command, Compare.NotIn, "A", "B")] + [AllowedValues("fallback", ConditionGroup = 3)] + Arg + } } diff --git a/Assets/TestData/CSV4Unity/ConditionalValidation.csv b/Assets/TestData/CSV4Unity/ConditionalValidation.csv new file mode 100644 index 0000000..b0cfec9 --- /dev/null +++ b/Assets/TestData/CSV4Unity/ConditionalValidation.csv @@ -0,0 +1,9 @@ +Command,Enabled,Arg +A,true, +A,false,invalid +A,true,invalid +A,true,10 +B,true,invalid +B,true,true +C,true,invalid +C,true,fallback diff --git a/Assets/TestData/CSV4Unity/ConditionalValidation.csv.meta b/Assets/TestData/CSV4Unity/ConditionalValidation.csv.meta new file mode 100644 index 0000000..a8dc5bd --- /dev/null +++ b/Assets/TestData/CSV4Unity/ConditionalValidation.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d1079a119b074fd69376bfca105b6a30 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 9ab8ddac99835e375784158a34bf130308063251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:32:14 +0900 Subject: [PATCH 06/28] =?UTF-8?q?docs:=20=E6=9D=A1=E4=BB=B6=E4=BB=98?= =?UTF-8?q?=E3=81=8D=E3=83=90=E3=83=AA=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E5=88=A9=E7=94=A8=E6=96=B9=E6=B3=95=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 48 +++++++++++++++++++++++++++++++++++++++++ README_EN.md | 19 ++++++++++++++++ docs/en/architecture.md | 12 +++++++---- docs/ja/architecture.md | 7 +++++- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 14e4eca..4fed9e7 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,54 @@ foreach (ValidationError error in result.Errors) 利用可能な制約は `PrimaryKey`、`NotNull`、`Unique`、`TypeConstraint`、`Range`、`Regex`、`AllowedValues`、`MinLength`、`MaxLength`、`ForeignKey` です。 +### 条件付きValidation + +`Condition`を付けると、条件が成立した行だけValidation属性を適用できます。同じグループの条件はすべてANDとして評価されます。 + +```csharp +public enum ScenarioField +{ + Command, + Enabled, + + [Condition(1, ScenarioField.Command, Compare.Equal, "Wait")] + [Condition(1, ScenarioField.Enabled, Compare.Equal, true)] + [NotNull(ConditionGroup = 1)] + [TypeConstraint(typeof(int), ConditionGroup = 1)] + + [Condition(2, ScenarioField.Command, Compare.Equal, "SetFlag")] + [NotNull(ConditionGroup = 2)] + [TypeConstraint(typeof(bool), ConditionGroup = 2)] + Arg1 +} +``` + +グループを省略した場合はグループ0になります。単一条件なら番号を記述する必要はありません。 + +```csharp +[Condition(ScenarioField.Command, Compare.In, "Text", "Choice")] +[NotNull] +Text +``` + +`Compare`は `Equal`、`NotEqual`、`GreaterThan`、`GreaterThanOrEqual`、`LessThan`、`LessThanOrEqual`、`IsEmpty`、`IsNotEmpty`、`In`、`NotIn` を使用できます。文字列比較は既定で大文字小文字を区別し、`IgnoreCase = true`で無視できます。数値比較では数値リテラルを渡してください。 + +```csharp +[Condition(ScenarioField.Duration, Compare.GreaterThan, 0)] +[Range(0, 10)] +Arg1 +``` + +比較値に同じEnum型のフィールドを指定すると、同じ行の列同士を比較します。 + +```csharp +[Condition(ScenarioField.Start, Compare.LessThanOrEqual, ScenarioField.End)] +[NotNull] +Text +``` + +Conditionは上から順に実行される`if / else`ではありません。各グループは独立して評価されるため、条件が重なると複数のValidationが同時に適用されます。else相当は`NotIn`や`NotEqual`で明示してください。 + ## Inspector Validation 1. `CSV4Unity.Fields` 名前空間へValidation用Enumを定義します。 diff --git a/README_EN.md b/README_EN.md index 081c68f..9fc1314 100644 --- a/README_EN.md +++ b/README_EN.md @@ -71,6 +71,25 @@ CsvTable table = CSVLoader.LoadTable(csvAsset); CsvValidationResult result = CsvValidator.Validate(table); ``` +Use `Condition` to apply validation attributes only to matching rows. Conditions in the same group are combined with AND. + +```csharp +public enum ScenarioField +{ + Command, + + [Condition(1, ScenarioField.Command, Compare.Equal, "Wait")] + [NotNull(ConditionGroup = 1)] + [TypeConstraint(typeof(int), ConditionGroup = 1)] + + [Condition(2, ScenarioField.Command, Compare.Equal, "SetFlag")] + [TypeConstraint(typeof(bool), ConditionGroup = 2)] + Arg1 +} +``` + +The default group is zero. Supported comparisons are `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `IsEmpty`, `IsNotEmpty`, `In`, and `NotIn`. Groups are declarative and have no `if / else` execution order. + The Japanese README is the canonical user documentation while the API is being stabilized. See [the architecture document](./docs/en/architecture.md) for the current class boundaries. ## License diff --git a/docs/en/architecture.md b/docs/en/architecture.md index a08bf43..392261d 100644 --- a/docs/en/architecture.md +++ b/docs/en/architecture.md @@ -30,7 +30,7 @@ Parsing Schema Conversion | | CsvIndex <--------------+ -Validation (next stage) +Validation depends on CsvTable, CsvEnumSchema, and CsvCell core data classes never depend on validation ``` @@ -121,12 +121,16 @@ Responsibility: adapt Unity inputs to the pure C# core. - Delegates all parsing to `CsvParser`. - Does not contain parsing, conversion, indexing, or validation algorithms. -## Planned validation boundary +## Validation boundary -Attribute metadata will be compiled into a validation schema once, then applied to `CsvTable`. Row-local rules and column/table rules must be separate: +`CsvValidationSchema` compiles attribute metadata once and `CsvValidator` applies the resulting rules to `CsvTable`. Row-local rules and column/table rules remain separate: - Row-local: required, type, range, regex, allowed values, length. - Column/table: primary key and unique. - Cross-document: foreign key through an explicit validation context. -This prevents `Unique` from rescanning an entire column once per row and prevents the core data model from depending on reflection or validation attributes. +`ConditionAttribute` limits a validation rule to matching rows. Conditions in the same `ConditionGroup` are combined with AND. Enum fields and groups are resolved while creating the validation schema, so row evaluation performs no reflection. Each validation attribute becomes one internal rule, allowing one CSV column to use different type constraints for different commands. + +The internal `CsvConditionEvaluator` owns condition comparison while `CsvValidator` remains responsible for applying validation constraints. + +This prevents `Unique` from rescanning an entire column once per row and keeps the core data model independent from reflection and validation attributes. diff --git a/docs/ja/architecture.md b/docs/ja/architecture.md index 3a69128..27e6c0d 100644 --- a/docs/ja/architecture.md +++ b/docs/ja/architecture.md @@ -187,6 +187,7 @@ if (index.TryFindFirst("Text", out int rowIndex)) | 型 | 役割 | |---|---| | `CsvValidationSchema` | Enum属性を一度読み取り、検証規則へ変換する | +| `CsvConditionEvaluator` | コンパイル済みConditionを行ごとに評価する内部クラス | | `CsvValidator` | `CsvTable` を規則に従って検証する | | `CsvValidationContext` | 外部キー検証で参照先CSVを登録する | | `CsvValidationResult` | エラーと警告を保持する | @@ -199,7 +200,11 @@ Validationは次の3種類へ分けます。 | 列全体 | `PrimaryKey`、`Unique` | | CSV間 | `ForeignKey` | -`PrimaryKey` と `Unique` は、各行の検証中に列全体を繰り返し走査せず、列ごとに一度だけ検証します。 +`ConditionAttribute`は対象フィールドに付いたValidation属性の適用行を限定します。スキーマ生成時に条件列をEnumへ解決し、Reflectionは行評価中に実行しません。同じ`ConditionGroup`の条件はANDで評価され、Validation属性は同じ番号の条件グループだけを参照します。グループ0にConditionがなければ、従来どおり無条件で適用します。 + +Validation属性は属性1個につき内部規則1個へ変換します。このため、同じ列へCommand別の`TypeConstraint`を複数定義できます。条件は制約を実行するかだけを決め、条件不成立自体をValidationエラーにはしません。 + +`PrimaryKey` と `Unique` は条件に一致する行集合を一度だけ走査します。無条件の場合も、各行のセル検証中に列全体を繰り返し走査しません。 ## 依存関係のルール From 09f60aa4627d108408f498c8d58e9ff508b73158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:08:12 +0900 Subject: [PATCH 07/28] =?UTF-8?q?feat:=20Validation=E3=82=B9=E3=82=AD?= =?UTF-8?q?=E3=83=BC=E3=83=9E=E8=A1=A8=E7=A4=BA=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CSVLoader/Editor/CsvInspectorEditor.cs | 183 +++++++++++++----- 1 file changed, 139 insertions(+), 44 deletions(-) diff --git a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs index 50d858f..6243adb 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs @@ -1,6 +1,7 @@ #if UNITY_EDITOR using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -121,32 +122,149 @@ private void DrawConstraints(Type enumType) { EditorGUILayout.Space(5); EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField($"Constraints: {enumType.Name}", EditorStyles.boldLabel); + EditorGUILayout.LabelField($"Schema Preview: {enumType.Name}", EditorStyles.boldLabel); + EditorGUILayout.Space(3); bool hasConstraints = false; FieldInfo[] fields = enumType.GetFields(BindingFlags.Public | BindingFlags.Static); for (int i = 0; i < fields.Length; i++) { object[] attributes = fields[i].GetCustomAttributes(false); - List labels = attributes - .OfType() - .Select(GetAttributeDisplayText) - .Where(label => label != null) - .ToList(); + ConditionAttribute[] conditions = attributes.OfType().ToArray(); + CsvValidationAttribute[] validations = attributes.OfType().ToArray(); + + if (conditions.Length == 0 && validations.Length == 0) continue; + if (hasConstraints) + { + EditorGUILayout.Space(5); + DrawSeparator(); + EditorGUILayout.Space(5); + } - if (labels.Count == 0) continue; hasConstraints = true; - EditorGUILayout.LabelField(fields[i].Name, string.Join(", ", labels)); + DrawFieldConstraints(fields[i].Name, conditions, validations); } if (!hasConstraints) { - EditorGUILayout.LabelField("制約属性は定義されていません。", EditorStyles.miniLabel); + EditorGUILayout.LabelField("No validation constraints are defined.", EditorStyles.miniLabel); } EditorGUILayout.EndVertical(); } + private static void DrawFieldConstraints( + string fieldName, + IReadOnlyList conditions, + IReadOnlyList validations) + { + EditorGUILayout.LabelField(fieldName, EditorStyles.boldLabel); + + int[] groups = conditions + .Select(condition => condition.Group) + .Concat(validations.Select(validation => validation.ConditionGroup)) + .Distinct() + .OrderBy(group => group) + .ToArray(); + + GUIStyle expressionStyle = new GUIStyle(EditorStyles.wordWrappedLabel) + { + padding = new RectOffset(8, 4, 1, 1) + }; + + for (int i = 0; i < groups.Length; i++) + { + int group = groups[i]; + ConditionAttribute[] groupConditions = conditions + .Where(condition => condition.Group == group) + .ToArray(); + CsvValidationAttribute[] groupValidations = validations + .Where(validation => validation.ConditionGroup == group) + .ToArray(); + + if (i > 0) EditorGUILayout.Space(4); + + string conditionExpression = groupConditions.Length == 0 + ? group == 0 ? "ALWAYS" : $"IF (GROUP {group} HAS NO CONDITION)" + : $"IF ({string.Join(" && ", groupConditions.Select(FormatCondition))})"; + EditorGUILayout.LabelField(conditionExpression, expressionStyle); + + string validationExpression = groupValidations.Length == 0 + ? "NO CONSTRAINT" + : string.Join(" && ", groupValidations.Select(GetValidationDisplayText)); + EditorGUILayout.LabelField( + $"=> {fieldName}: {validationExpression}", + expressionStyle); + } + } + + private static string FormatCondition(ConditionAttribute condition) + { + string field = condition.Field?.ToString() ?? "null"; + string suffix = condition.IgnoreCase ? " [IGNORE CASE]" : string.Empty; + + switch (condition.Comparison) + { + case Compare.Equal: + return $"{field} == {FormatSingleValue(condition)}{suffix}"; + case Compare.NotEqual: + return $"{field} != {FormatSingleValue(condition)}{suffix}"; + case Compare.GreaterThan: + return $"{field} > {FormatSingleValue(condition)}"; + case Compare.GreaterThanOrEqual: + return $"{field} >= {FormatSingleValue(condition)}"; + case Compare.LessThan: + return $"{field} < {FormatSingleValue(condition)}"; + case Compare.LessThanOrEqual: + return $"{field} <= {FormatSingleValue(condition)}"; + case Compare.IsEmpty: + return $"{field} IS EMPTY"; + case Compare.IsNotEmpty: + return $"{field} IS NOT EMPTY"; + case Compare.In: + return $"{field} IN ({string.Join(", ", condition.Values.Select(FormatValue))}){suffix}"; + case Compare.NotIn: + return $"{field} NOT IN ({string.Join(", ", condition.Values.Select(FormatValue))}){suffix}"; + default: + return $"{field} {condition.Comparison}"; + } + } + + private static string FormatSingleValue(ConditionAttribute condition) + { + return condition.Values.Length == 0 ? "" : FormatValue(condition.Values[0]); + } + + private static string FormatValue(object value) + { + switch (value) + { + case null: + return "null"; + case string text: + return $"\"{EscapeValue(text)}\""; + case char character: + return $"'{EscapeValue(character.ToString())}'"; + case bool boolean: + return boolean ? "true" : "false"; + case Enum enumValue: + return enumValue.ToString(); + case IFormattable formattable: + return formattable.ToString(null, CultureInfo.InvariantCulture); + default: + return value.ToString(); + } + } + + private static string EscapeValue(string value) + { + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } + private void ExecuteValidation() { if (_selectedEnumType == null || _csvFile == null) return; @@ -292,56 +410,33 @@ private static string GetSelectionKey(string assetPath) return $"CSV4Unity.SelectedEnum.{AssetDatabase.AssetPathToGUID(assetPath)}"; } - private static string GetAttributeDisplayText(Attribute attribute) + private static string GetValidationDisplayText(CsvValidationAttribute attribute) { - string label; switch (attribute) { - case ConditionAttribute condition: - string values = condition.Values.Length == 0 - ? string.Empty - : $" {string.Join("|", condition.Values)}"; - string group = condition.Group == 0 ? string.Empty : $" Group {condition.Group}:"; - return $"[Condition:{group} {condition.Field} {condition.Comparison}{values}]"; case PrimaryKeyAttribute: - label = "PrimaryKey"; - break; + return "PRIMARY KEY"; case NotNullAttribute: - label = "NotNull"; - break; + return "VALUE IS NOT EMPTY"; case UniqueAttribute: - label = "Unique"; - break; + return "UNIQUE"; case TypeConstraintAttribute typeConstraint: - label = $"Type: {typeConstraint.ExpectedType.Name}"; - break; + return $"TYPE = {typeConstraint.ExpectedType.Name}"; case Validation.RangeAttribute range: - label = $"Range: {range.Min}-{range.Max}"; - break; + return $"{FormatValue(range.Min)} <= VALUE <= {FormatValue(range.Max)}"; case RegexAttribute regex: - label = $"Regex: {regex.Pattern}"; - break; + return $"MATCHES {FormatValue(regex.Pattern)}"; case AllowedValuesAttribute allowed: - label = $"Allowed: {string.Join("|", allowed.AllowedValues)}"; - break; + return $"VALUE IN ({string.Join(", ", allowed.AllowedValues.Select(FormatValue))})"; case MinLengthAttribute minLength: - label = $"MinLength: {minLength.MinLength}"; - break; + return $"LENGTH >= {minLength.MinLength}"; case MaxLengthAttribute maxLength: - label = $"MaxLength: {maxLength.MaxLength}"; - break; + return $"LENGTH <= {maxLength.MaxLength}"; case ForeignKeyAttribute foreignKey: - label = $"ForeignKey: {foreignKey.ReferenceEnumType.Name}.{foreignKey.ReferenceField}"; - break; + return $"REFERENCES {foreignKey.ReferenceEnumType.Name}.{foreignKey.ReferenceField}"; default: - return null; + return attribute.GetType().Name; } - - var validation = (CsvValidationAttribute)attribute; - string conditionGroup = validation.ConditionGroup == 0 - ? string.Empty - : $", Group: {validation.ConditionGroup}"; - return $"[{label}{conditionGroup}]"; } private static CsvValidationResult ValidateDocument(CsvDocument document) From 859473b8758b27de074ea67a417b90fca7426ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:55:11 +0900 Subject: [PATCH 08/28] =?UTF-8?q?fix:=20=E3=82=B9=E3=82=AD=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E4=BE=8B=E5=A4=96=E3=81=AE=E3=83=A9=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=82=92=E9=98=B2=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs | 5 ++++- Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs index 7feaab3..ef43cf8 100644 --- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs +++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationSchema.cs @@ -13,6 +13,9 @@ namespace CSV4Unity.Validation /// 生成後の規則は変更されず、複数ののValidationに再利用できます。 public sealed class CsvValidationSchema where TField : struct, Enum { + private static readonly Lazy> DefaultSchema = + new Lazy>(Create); + private readonly CsvFieldValidationRule[] _rules; private CsvValidationSchema(CsvFieldValidationRule[] rules) @@ -21,7 +24,7 @@ private CsvValidationSchema(CsvFieldValidationRule[] rules) } /// Enum型ごとに一度生成される既定スキーマを取得します。 - public static CsvValidationSchema Default { get; } = Create(); + public static CsvValidationSchema Default => DefaultSchema.Value; /// Enumに定義されたValidation属性の総数を取得します。 public int RuleCount => _rules.Length; diff --git a/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs b/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs index 433eb9d..59a635e 100644 --- a/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs +++ b/Assets/Scripts/Tests/EditMode/CsvValidationTableTests.cs @@ -356,6 +356,13 @@ public void CreateSchema_UnknownComparison_ThrowsSchemaException() Assert.Throws(() => CsvValidationSchema.Create()); } + [Test] + public void DefaultSchema_UnknownComparison_ThrowsSchemaException() + { + Assert.Throws(() => + Assert.That(CsvValidationSchema.Default, Is.Not.Null)); + } + [Test] public void Validate_ForeignKey_UsesRegisteredReferenceTable() { From 1e6bce290b42cc12b8b7ffaf756a0b71e15d87cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:55:38 +0900 Subject: [PATCH 09/28] =?UTF-8?q?feat:=20=E8=AA=AD=E3=81=BF=E5=8F=96?= =?UTF-8?q?=E3=82=8A=E5=B0=82=E7=94=A8CSV=20Viewer=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CSVLoader/Editor/CsvInspectorEditor.cs | 10 + .../CSVLoader/Editor/CsvViewerTable.cs | 347 ++++++++++++++++++ .../CSVLoader/Editor/CsvViewerTable.cs.meta | 2 + .../CSVLoader/Editor/CsvViewerWindow.cs | 241 ++++++++++++ .../CSVLoader/Editor/CsvViewerWindow.cs.meta | 2 + 5 files changed, 602 insertions(+) create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs.meta create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs.meta diff --git a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs index 6243adb..dccffe9 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs @@ -61,6 +61,16 @@ public override void OnInspectorGUI() private void DrawCsvValidationControls() { + EditorGUILayout.Space(10); + DrawSeparator(); + EditorGUILayout.Space(5); + + EditorGUILayout.LabelField("CSV Viewer", EditorStyles.boldLabel); + if (GUILayout.Button("Open CSV Viewer", GUILayout.Height(26))) + { + CsvViewerWindow.Open(_csvFile); + } + EditorGUILayout.Space(10); DrawSeparator(); EditorGUILayout.Space(5); diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs new file mode 100644 index 0000000..dd11b82 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs @@ -0,0 +1,347 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace CSV4Unity.Editor +{ + /// + /// CsvDocumentを仮想スクロールする読み取り専用テーブルです。 + /// + internal sealed class CsvViewerTable + { + private const float HeaderHeight = 24f; + private const float RowHeight = 21f; + private const float RowNumberWidth = 56f; + private const float MinimumColumnWidth = 64f; + private const float MaximumInitialColumnWidth = 280f; + private const int CellCacheRowLimit = 256; + + private readonly CsvDocument _document; + private readonly float[] _columnWidths; + private readonly List _filteredRows = new List(); + private readonly Dictionary _cellTextCache = new Dictionary(); + + private Vector2 _scrollPosition; + private string _searchText = string.Empty; + private int _selectedDisplayRow = -1; + private int _selectedColumn = -1; + private int _resizingColumn = -1; + private float _resizeStartMouseX; + private float _resizeStartWidth; + + public CsvViewerTable(CsvDocument document) + { + _document = document ?? throw new ArgumentNullException(nameof(document)); + _columnWidths = CreateInitialColumnWidths(document); + } + + public int RowCount => _document.RowCount; + + public int ColumnCount => _document.ColumnCount; + + public int FilteredRowCount => string.IsNullOrEmpty(_searchText) ? RowCount : _filteredRows.Count; + + public void SetSearch(string searchText) + { + string normalized = searchText ?? string.Empty; + if (string.Equals(_searchText, normalized, StringComparison.Ordinal)) return; + + _searchText = normalized; + _filteredRows.Clear(); + _selectedDisplayRow = -1; + _selectedColumn = -1; + _scrollPosition.y = 0f; + + if (string.IsNullOrEmpty(_searchText)) return; + + ReadOnlySpan query = _searchText.AsSpan(); + for (int rowIndex = 0; rowIndex < RowCount; rowIndex++) + { + if (RowContains(rowIndex, query)) _filteredRows.Add(rowIndex); + } + } + + public void OnGUI(Rect rect) + { + if (rect.width <= 0f || rect.height <= HeaderHeight) return; + + Rect headerRect = new Rect(rect.x, rect.y, rect.width, HeaderHeight); + Rect bodyRect = new Rect(rect.x, rect.y + HeaderHeight, rect.width, rect.height - HeaderHeight); + float contentWidth = CalculateContentWidth(); + float contentHeight = Mathf.Max(bodyRect.height, FilteredRowCount * RowHeight); + + _scrollPosition = GUI.BeginScrollView( + bodyRect, + _scrollPosition, + new Rect(0f, 0f, contentWidth, contentHeight), + true, + true); + DrawVisibleRows(bodyRect.height, contentWidth); + HandleCopyShortcut(); + GUI.EndScrollView(); + + DrawHeader(headerRect, contentWidth); + } + + private void DrawHeader(Rect rect, float contentWidth) + { + EditorGUI.DrawRect(rect, new Color(0.16f, 0.16f, 0.16f, 1f)); + GUI.BeginGroup(rect); + + float x = -_scrollPosition.x; + DrawHeaderCell(new Rect(x, 0f, RowNumberWidth, HeaderHeight), "#"); + x += RowNumberWidth; + + for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) + { + float width = _columnWidths[columnIndex]; + string name = _document.HasHeader + ? _document.Headers[columnIndex] + : $"Column {columnIndex + 1}"; + DrawHeaderCell(new Rect(x, 0f, width, HeaderHeight), name); + HandleColumnResize(columnIndex, new Rect(x + width - 3f, 0f, 6f, HeaderHeight)); + x += width; + } + + if (x < contentWidth - _scrollPosition.x) + { + EditorGUI.DrawRect( + new Rect(x, 0f, contentWidth - _scrollPosition.x - x, HeaderHeight), + new Color(0.16f, 0.16f, 0.16f, 1f)); + } + + GUI.EndGroup(); + } + + private static void DrawHeaderCell(Rect rect, string text) + { + GUI.Box(rect, GUIContent.none, EditorStyles.toolbarButton); + GUI.Label( + new Rect(rect.x + 6f, rect.y + 2f, Mathf.Max(0f, rect.width - 12f), rect.height - 4f), + new GUIContent(text, text), + EditorStyles.boldLabel); + } + + private void DrawVisibleRows(float viewportHeight, float contentWidth) + { + int firstRow = Mathf.Max(0, Mathf.FloorToInt(_scrollPosition.y / RowHeight)); + int visibleCount = Mathf.CeilToInt(viewportHeight / RowHeight) + 2; + int lastRow = Mathf.Min(FilteredRowCount, firstRow + visibleCount); + + for (int displayRow = firstRow; displayRow < lastRow; displayRow++) + { + int sourceRow = GetSourceRow(displayRow); + float y = displayRow * RowHeight; + Rect rowRect = new Rect(0f, y, contentWidth, RowHeight); + + if ((displayRow & 1) != 0) + { + EditorGUI.DrawRect(rowRect, new Color(1f, 1f, 1f, 0.025f)); + } + + DrawCellBackgrounds(displayRow, rowRect); + + float x = 0f; + GUI.Label( + new Rect(x + 5f, y + 1f, RowNumberWidth - 10f, RowHeight - 2f), + (sourceRow + 1).ToString(), + EditorStyles.miniLabel); + x += RowNumberWidth; + + string[] cellTexts = GetRowTexts(sourceRow); + for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) + { + float width = _columnWidths[columnIndex]; + Rect cellRect = new Rect(x, y, width, RowHeight); + DrawCell(displayRow, columnIndex, cellRect, cellTexts[columnIndex]); + x += width; + } + + EditorGUI.DrawRect(new Rect(0f, y + RowHeight - 1f, contentWidth, 1f), new Color(0f, 0f, 0f, 0.16f)); + } + } + + private void DrawCellBackgrounds(int displayRow, Rect rowRect) + { + if (displayRow != _selectedDisplayRow) return; + EditorGUI.DrawRect(rowRect, new Color(0.24f, 0.49f, 0.90f, 0.12f)); + } + + private void DrawCell(int displayRow, int columnIndex, Rect rect, string text) + { + if (displayRow == _selectedDisplayRow && columnIndex == _selectedColumn) + { + EditorGUI.DrawRect(rect, new Color(0.24f, 0.49f, 0.90f, 0.28f)); + } + + GUI.Label( + new Rect(rect.x + 5f, rect.y + 1f, Mathf.Max(0f, rect.width - 10f), rect.height - 2f), + new GUIContent(text, text), + EditorStyles.label); + EditorGUI.DrawRect(new Rect(rect.x + rect.width - 1f, rect.y, 1f, rect.height), new Color(0f, 0f, 0f, 0.14f)); + + Event current = Event.current; + if (!rect.Contains(current.mousePosition)) return; + + if (current.type == EventType.MouseDown && current.button == 0) + { + _selectedDisplayRow = displayRow; + _selectedColumn = columnIndex; + current.Use(); + } + else if (current.type == EventType.ContextClick) + { + _selectedDisplayRow = displayRow; + _selectedColumn = columnIndex; + ShowContextMenu(); + current.Use(); + } + } + + private void ShowContextMenu() + { + var menu = new GenericMenu(); + menu.AddItem(new GUIContent("Copy Cell"), false, CopySelectedCell); + menu.AddItem(new GUIContent("Copy Row"), false, CopySelectedRow); + menu.ShowAsContext(); + } + + private void HandleCopyShortcut() + { + Event current = Event.current; + if (current.type != EventType.KeyDown || current.keyCode != KeyCode.C) return; + if (!current.control && !current.command) return; + + CopySelectedCell(); + current.Use(); + } + + private void CopySelectedCell() + { + if (!TryGetSelectedSourceRow(out int sourceRow) || _selectedColumn < 0) return; + GUIUtility.systemCopyBuffer = _document.Cell(sourceRow, _selectedColumn).GetString(); + } + + private void CopySelectedRow() + { + if (!TryGetSelectedSourceRow(out int sourceRow)) return; + + string[] values = GetRowTexts(sourceRow); + var builder = new StringBuilder(); + for (int columnIndex = 0; columnIndex < values.Length; columnIndex++) + { + if (columnIndex > 0) builder.Append('\t'); + builder.Append(_document.Cell(sourceRow, columnIndex).GetString()); + } + + GUIUtility.systemCopyBuffer = builder.ToString(); + } + + private bool TryGetSelectedSourceRow(out int sourceRow) + { + if ((uint)_selectedDisplayRow >= (uint)FilteredRowCount) + { + sourceRow = -1; + return false; + } + + sourceRow = GetSourceRow(_selectedDisplayRow); + return true; + } + + private void HandleColumnResize(int columnIndex, Rect handleRect) + { + EditorGUIUtility.AddCursorRect(handleRect, MouseCursor.ResizeHorizontal); + Event current = Event.current; + + if (current.type == EventType.MouseDown && current.button == 0 && handleRect.Contains(current.mousePosition)) + { + _resizingColumn = columnIndex; + _resizeStartMouseX = current.mousePosition.x; + _resizeStartWidth = _columnWidths[columnIndex]; + current.Use(); + } + else if (current.type == EventType.MouseDrag && _resizingColumn == columnIndex) + { + float delta = current.mousePosition.x - _resizeStartMouseX; + _columnWidths[columnIndex] = Mathf.Max(MinimumColumnWidth, _resizeStartWidth + delta); + current.Use(); + } + else if (current.type == EventType.MouseUp && _resizingColumn == columnIndex) + { + _resizingColumn = -1; + current.Use(); + } + } + + private bool RowContains(int rowIndex, ReadOnlySpan query) + { + for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) + { + CsvCell cell = _document.Cell(rowIndex, columnIndex); + if (cell.RawSpan.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0) return true; + } + + return false; + } + + private int GetSourceRow(int displayRow) + { + return string.IsNullOrEmpty(_searchText) ? displayRow : _filteredRows[displayRow]; + } + + private string[] GetRowTexts(int sourceRow) + { + if (_cellTextCache.TryGetValue(sourceRow, out string[] values)) return values; + + if (_cellTextCache.Count >= CellCacheRowLimit) _cellTextCache.Clear(); + + values = new string[ColumnCount]; + for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) + { + values[columnIndex] = ToSingleLine(_document.Cell(sourceRow, columnIndex).GetString()); + } + + _cellTextCache.Add(sourceRow, values); + return values; + } + + private static string ToSingleLine(string value) + { + return value + .Replace("\r\n", "\\n") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } + + private float CalculateContentWidth() + { + float width = RowNumberWidth; + for (int i = 0; i < _columnWidths.Length; i++) width += _columnWidths[i]; + return width; + } + + private static float[] CreateInitialColumnWidths(CsvDocument document) + { + var widths = new float[document.ColumnCount]; + int sampleRows = Math.Min(document.RowCount, 100); + + for (int columnIndex = 0; columnIndex < document.ColumnCount; columnIndex++) + { + int longest = document.HasHeader ? document.Headers[columnIndex].Length : 8; + for (int rowIndex = 0; rowIndex < sampleRows; rowIndex++) + { + longest = Math.Max(longest, Math.Min(document.Cell(rowIndex, columnIndex).RawSpan.Length, 40)); + } + + widths[columnIndex] = Mathf.Clamp((longest * 7f) + 24f, 110f, MaximumInitialColumnWidth); + } + + return widths; + } + } +} +#endif diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs.meta new file mode 100644 index 0000000..87366fd --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 03c8fe8a653f410bb74233580e3eca48 diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs new file mode 100644 index 0000000..9b44ef2 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs @@ -0,0 +1,241 @@ +#if UNITY_EDITOR +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace CSV4Unity.Editor +{ + /// + /// CSVの内容を読み取り専用の表として表示します。 + /// + public sealed class CsvViewerWindow : EditorWindow + { + private const float ToolbarHeight = 22f; + private const float StatusHeight = 20f; + + [SerializeField] private TextAsset _csvAsset; + [SerializeField] private bool _hasHeader = true; + [SerializeField] private string _searchText = string.Empty; + + [NonSerialized] private CsvViewerTable _table; + [NonSerialized] private string _errorMessage; + [NonSerialized] private Hash128 _assetHash; + + /// 指定したCSVをViewerで開きます。 + /// 表示するCSVのTextAsset。 + public static void Open(TextAsset csvAsset) + { + CsvViewerWindow window = GetWindow(); + window.titleContent = new GUIContent("CSV Viewer"); + window.minSize = new Vector2(480f, 260f); + window.SetAsset(csvAsset); + window.Show(); + } + + [MenuItem("Window/CSV4Unity/CSV Viewer")] + private static void OpenWindow() + { + CsvViewerWindow window = GetWindow(); + window.titleContent = new GUIContent("CSV Viewer"); + window.minSize = new Vector2(480f, 260f); + window.Show(); + } + + [MenuItem("Assets/Open in CSV Viewer", false, 2000)] + private static void OpenSelectedAsset() + { + Open(Selection.activeObject as TextAsset); + } + + [MenuItem("Assets/Open in CSV Viewer", true)] + private static bool CanOpenSelectedAsset() + { + if (!(Selection.activeObject is TextAsset textAsset)) return false; + string path = AssetDatabase.GetAssetPath(textAsset); + return string.Equals(Path.GetExtension(path), ".csv", StringComparison.OrdinalIgnoreCase); + } + + private void OnEnable() + { + titleContent = new GUIContent("CSV Viewer"); + minSize = new Vector2(480f, 260f); + EditorApplication.projectChanged += HandleProjectChanged; + Reload(); + } + + private void OnDisable() + { + EditorApplication.projectChanged -= HandleProjectChanged; + } + + private void OnGUI() + { + DrawToolbar(new Rect(0f, 0f, position.width, ToolbarHeight)); + + Rect contentRect = new Rect( + 0f, + ToolbarHeight, + position.width, + Mathf.Max(0f, position.height - ToolbarHeight - StatusHeight)); + + if (!string.IsNullOrEmpty(_errorMessage)) + { + EditorGUI.HelpBox( + new Rect(contentRect.x + 8f, contentRect.y + 8f, contentRect.width - 16f, 44f), + _errorMessage, + MessageType.Error); + } + else if (_table != null) + { + _table.OnGUI(contentRect); + } + else + { + EditorGUI.HelpBox( + new Rect(contentRect.x + 8f, contentRect.y + 8f, contentRect.width - 16f, 38f), + "Select a CSV TextAsset to preview.", + MessageType.Info); + } + + DrawStatusBar(new Rect(0f, position.height - StatusHeight, position.width, StatusHeight)); + } + + private void DrawToolbar(Rect rect) + { + GUILayout.BeginArea(rect, EditorStyles.toolbar); + GUILayout.BeginHorizontal(); + + EditorGUI.BeginChangeCheck(); + TextAsset selectedAsset = (TextAsset)EditorGUILayout.ObjectField( + _csvAsset, + typeof(TextAsset), + false, + GUILayout.MinWidth(140f)); + if (EditorGUI.EndChangeCheck()) SetAsset(selectedAsset); + + EditorGUI.BeginChangeCheck(); + bool hasHeader = GUILayout.Toggle(_hasHeader, "Header", EditorStyles.toolbarButton, GUILayout.Width(58f)); + if (EditorGUI.EndChangeCheck()) + { + _hasHeader = hasHeader; + Reload(); + } + + if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(54f))) Reload(); + + GUILayout.FlexibleSpace(); + EditorGUI.BeginChangeCheck(); + string search = EditorGUILayout.TextField( + _searchText ?? string.Empty, + EditorStyles.toolbarSearchField, + GUILayout.MinWidth(100f), + GUILayout.MaxWidth(240f)); + if (EditorGUI.EndChangeCheck()) + { + _searchText = search; + _table?.SetSearch(_searchText); + Repaint(); + } + + using (new EditorGUI.DisabledScope(string.IsNullOrEmpty(_searchText))) + { + if (GUILayout.Button(new GUIContent("x", "Clear search"), EditorStyles.toolbarButton, GUILayout.Width(20f))) + { + _searchText = string.Empty; + _table?.SetSearch(_searchText); + GUI.FocusControl(null); + Repaint(); + } + } + + GUILayout.EndHorizontal(); + GUILayout.EndArea(); + } + + private void DrawStatusBar(Rect rect) + { + EditorGUI.DrawRect(rect, new Color(0f, 0f, 0f, 0.12f)); + + string status; + if (_table == null) + { + status = _csvAsset == null ? "No CSV selected" : "CSV unavailable"; + } + else + { + status = _table.FilteredRowCount == _table.RowCount + ? $"{_table.RowCount:N0} rows, {_table.ColumnCount:N0} columns" + : $"{_table.FilteredRowCount:N0} of {_table.RowCount:N0} rows, {_table.ColumnCount:N0} columns"; + } + + GUI.Label(new Rect(rect.x + 6f, rect.y + 2f, rect.width - 12f, rect.height - 2f), status, EditorStyles.miniLabel); + } + + private void SetAsset(TextAsset csvAsset) + { + if (_csvAsset == csvAsset) return; + _csvAsset = csvAsset; + _searchText = string.Empty; + Reload(); + } + + private void Reload() + { + _table = null; + _errorMessage = null; + _assetHash = default; + + if (_csvAsset == null) + { + Repaint(); + return; + } + + string assetPath = AssetDatabase.GetAssetPath(_csvAsset); + if (!string.Equals(Path.GetExtension(assetPath), ".csv", StringComparison.OrdinalIgnoreCase)) + { + _errorMessage = "The selected TextAsset is not a .csv file."; + Repaint(); + return; + } + + try + { + var options = new CsvParseOptions + { + HasHeader = _hasHeader, + IgnoreEmptyRecords = false, + TrimUnquotedFields = false + }; + + CsvDocument document = CSVLoader.LoadDocument(_csvAsset, options); + _table = new CsvViewerTable(document); + _table.SetSearch(_searchText); + _assetHash = AssetDatabase.GetAssetDependencyHash(assetPath); + } + catch (Exception exception) + { + _errorMessage = exception.Message; + } + + Repaint(); + } + + private void HandleProjectChanged() + { + if (_csvAsset == null) return; + + string assetPath = AssetDatabase.GetAssetPath(_csvAsset); + if (string.IsNullOrEmpty(assetPath)) + { + Reload(); + return; + } + + Hash128 currentHash = AssetDatabase.GetAssetDependencyHash(assetPath); + if (currentHash != _assetHash) Reload(); + } + } +} +#endif diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs.meta new file mode 100644 index 0000000..684ae72 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a990428c1ef408a92ef4769a33464d3 From 98d094fb58338346576b9727ab6afde8dbf03a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:56:06 +0900 Subject: [PATCH 10/28] =?UTF-8?q?test:=20CSV=20Viewer=E3=81=AE=E6=A4=9C?= =?UTF-8?q?=E7=B4=A2=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Plugins/CSVLoader/Editor/AssemblyInfo.cs | 3 ++ .../CSVLoader/Editor/AssemblyInfo.cs.meta | 2 + .../EditMode/CSV4Unity.Tests.EditMode.asmdef | 3 +- .../Tests/EditMode/CsvViewerTableTests.cs | 52 +++++++++++++++++++ .../EditMode/CsvViewerTableTests.cs.meta | 2 + 5 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs create mode 100644 Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs.meta create mode 100644 Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs create mode 100644 Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs.meta diff --git a/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs b/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs new file mode 100644 index 0000000..a89f73b --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSV4Unity.Tests.EditMode")] diff --git a/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs.meta b/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs.meta new file mode 100644 index 0000000..68d2ad7 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 27e8d8a387a3487894f81f54dbc64647 diff --git a/Assets/Scripts/Tests/EditMode/CSV4Unity.Tests.EditMode.asmdef b/Assets/Scripts/Tests/EditMode/CSV4Unity.Tests.EditMode.asmdef index bc3a228..da5b18a 100644 --- a/Assets/Scripts/Tests/EditMode/CSV4Unity.Tests.EditMode.asmdef +++ b/Assets/Scripts/Tests/EditMode/CSV4Unity.Tests.EditMode.asmdef @@ -2,7 +2,8 @@ "name": "CSV4Unity.Tests.EditMode", "rootNamespace": "CSV4Unity.Tests", "references": [ - "CSV4Unity.Runtime" + "CSV4Unity.Runtime", + "CSV4Unity.Editor" ], "includePlatforms": [ "Editor" diff --git a/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs b/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs new file mode 100644 index 0000000..cc45030 --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs @@ -0,0 +1,52 @@ +using CSV4Unity.Editor; +using NUnit.Framework; + +namespace CSV4Unity.Tests.EditMode +{ + public sealed class CsvViewerTableTests + { + private const string Csv = + "Name,Text\n" + + "Alice,Hello\n" + + "Bob,\"First line\nSecond line\"\n" + + "Carol,\"He said \"\"Hi\"\"\""; + + [Test] + public void Constructor_ReportsDocumentDimensions() + { + CsvDocument document = CSVLoader.LoadDocument(Csv); + var table = new CsvViewerTable(document); + + Assert.That(table.RowCount, Is.EqualTo(3)); + Assert.That(table.ColumnCount, Is.EqualTo(2)); + Assert.That(table.FilteredRowCount, Is.EqualTo(3)); + } + + [TestCase("alice", 1)] + [TestCase("HELLO", 1)] + [TestCase("second line", 1)] + [TestCase("hi", 1)] + [TestCase("missing", 0)] + public void SetSearch_FiltersRowsWithoutCaseSensitivity(string search, int expectedRows) + { + CsvDocument document = CSVLoader.LoadDocument(Csv); + var table = new CsvViewerTable(document); + + table.SetSearch(search); + + Assert.That(table.FilteredRowCount, Is.EqualTo(expectedRows)); + } + + [Test] + public void SetSearch_EmptyTextRestoresAllRows() + { + CsvDocument document = CSVLoader.LoadDocument(Csv); + var table = new CsvViewerTable(document); + table.SetSearch("Alice"); + + table.SetSearch(string.Empty); + + Assert.That(table.FilteredRowCount, Is.EqualTo(3)); + } + } +} diff --git a/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs.meta b/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs.meta new file mode 100644 index 0000000..ff55a99 --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 05ea1a59813642fca4ddd5c7fa5cf487 From 2f970846db6ea69638fed12ca5e714b8d229a1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:56:28 +0900 Subject: [PATCH 11/28] =?UTF-8?q?docs:=20CSV=20Viewer=E3=81=AE=E5=88=A9?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E6=B3=95=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +++++++++++++ README_EN.md | 7 +++++++ docs/en/architecture.md | 8 ++++++++ docs/ja/architecture.md | 10 ++++++++++ 4 files changed, 38 insertions(+) diff --git a/README.md b/README.md index 4fed9e7..b769842 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ CSVをクラスへ一括変換せず、セルを必要なときに指定した - 検索用インデックスの明示生成 - Enum属性によるValidation - CSV Inspectorからの手動Validation +- 読み取り専用CSV Viewer ## インストール @@ -242,6 +243,18 @@ Text Conditionは上から順に実行される`if / else`ではありません。各グループは独立して評価されるため、条件が重なると複数のValidationが同時に適用されます。else相当は`NotIn`や`NotEqual`で明示してください。 +## CSV Viewer + +ProjectウィンドウでCSVを選択し、Inspectorの `Open CSV Viewer` を押すと、CSVを読み取り専用の表として確認できます。CSVを右クリックして `Open in CSV Viewer` を選ぶか、`Window > CSV4Unity > CSV Viewer` から開くこともできます。 + +- `Header` で先頭行をヘッダーとして扱うか切り替え +- 検索欄で全セルを大文字小文字を区別せず絞り込み +- ヘッダー境界のドラッグで列幅を変更 +- セルの右クリックでセルまたは行をコピー +- CSVアセット更新時に自動再読込 + +Viewerは画面に見える行だけを描画し、検索時もセル文字列の全コピーを作りません。CSVの編集や保存は行わず、表示にはRuntimeと同じParserを使用します。 + ## Inspector Validation 1. `CSV4Unity.Fields` 名前空間へValidation用Enumを定義します。 diff --git a/README_EN.md b/README_EN.md index 9fc1314..65823a3 100644 --- a/README_EN.md +++ b/README_EN.md @@ -23,6 +23,7 @@ CSV4Unity reads CSV text into row, column, and cell views for Unity. Values rema - Explicitly created search indices - Attribute-based validation - Unity Inspector validation +- Read-only CSV Viewer ## Installation @@ -90,6 +91,12 @@ public enum ScenarioField The default group is zero. Supported comparisons are `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `IsEmpty`, `IsNotEmpty`, `In`, and `NotIn`. Groups are declarative and have no `if / else` execution order. +## CSV Viewer + +Select a CSV asset and click `Open CSV Viewer` in the Inspector. The viewer is also available from `Assets > Open in CSV Viewer` and `Window > CSV4Unity > CSV Viewer`. + +The read-only table supports headerless files, case-insensitive search, resizable columns, cell or row copying, and automatic reload after asset changes. It virtualizes row drawing and uses the same parser as the Runtime API. + The Japanese README is the canonical user documentation while the API is being stabilized. See [the architecture document](./docs/en/architecture.md) for the current class boundaries. ## License diff --git a/docs/en/architecture.md b/docs/en/architecture.md index 392261d..d4e5b55 100644 --- a/docs/en/architecture.md +++ b/docs/en/architecture.md @@ -121,6 +121,14 @@ Responsibility: adapt Unity inputs to the pure C# core. - Delegates all parsing to `CsvParser`. - Does not contain parsing, conversion, indexing, or validation algorithms. +### Unity Editor tools + +- `CsvInspectorEditor` adds the viewer and validation entry points to CSV assets. +- `CsvViewerWindow` owns asset selection, parsing, search state, and reload behavior. +- `CsvViewerTable` draws only visible rows and provides column resizing and copy commands. + +The viewer treats `CsvDocument` as read-only data. It caches display strings for at most 256 rows instead of duplicating the complete CSV as a two-dimensional string array. Editing and writing remain separate future responsibilities. + ## Validation boundary `CsvValidationSchema` compiles attribute metadata once and `CsvValidator` applies the resulting rules to `CsvTable`. Row-local rules and column/table rules remain separate: diff --git a/docs/ja/architecture.md b/docs/ja/architecture.md index 27e6c0d..5f3f347 100644 --- a/docs/ja/architecture.md +++ b/docs/ja/architecture.md @@ -147,6 +147,16 @@ public enum ItemField 対応候補が0件または複数件の場合や、複数のEnumフィールドが同じCSV列へ対応した場合は、曖昧なスキーマとして `CsvSchemaException` を送出します。 +### Unity Editor + +| 型 | 役割 | +|---|---| +| `CsvInspectorEditor` | CSV InspectorへViewerとValidationの入口を追加する | +| `CsvViewerWindow` | CSVアセットの選択、解析、検索条件、再読込を管理する | +| `CsvViewerTable` | 表示範囲の行だけを描画し、列幅変更とコピー操作を提供する | + +ViewerはEditor専用であり、`CsvDocument`を読み取り専用データとして利用します。表示用文字列は最大256行分だけキャッシュし、CSV全体を表示専用の二次元文字列配列へ複製しません。編集や書き出しは別の責務とします。 + ### 型変換 | 型 | 役割 | From e07fd46e84854c1066e2ab4a14e46d59cd8d2b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:36:41 +0900 Subject: [PATCH 12/28] =?UTF-8?q?feat:=20CSV=E6=96=87=E5=AD=97=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=AE=E6=A4=9C=E6=9F=BB=E3=81=A8UTF-8?= =?UTF-8?q?=E5=A4=89=E6=8F=9B=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CSVLoader/Editor/CsvEncodingUtility.cs | 261 ++++++++++++++++++ .../Editor/CsvEncodingUtility.cs.meta | 2 + .../CSVLoader/Editor/CsvInspectorEditor.cs | 200 +++++++++++++- 3 files changed, 457 insertions(+), 6 deletions(-) create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs.meta diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs b/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs new file mode 100644 index 0000000..bc5b670 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs @@ -0,0 +1,261 @@ +#if UNITY_EDITOR +using System; +using System.Text; + +namespace CSV4Unity.Editor +{ + /// + /// CSVファイルの変換元文字コードを表します。 + /// + internal enum CsvSourceEncoding + { + Auto, + Utf8, + ShiftJis, + Utf16LittleEndian, + Utf16BigEndian, + Utf32LittleEndian, + Utf32BigEndian + } + + /// + /// CSVファイルの文字コード検査結果を保持します。 + /// + internal readonly struct CsvEncodingInspection + { + public CsvEncodingInspection( + CsvSourceEncoding encoding, + bool hasBom, + bool isValid, + string text, + string errorMessage) + { + Encoding = encoding; + HasBom = hasBom; + IsValid = isValid; + Text = text; + ErrorMessage = errorMessage; + } + + public CsvSourceEncoding Encoding { get; } + public bool HasBom { get; } + public bool IsValid { get; } + public string Text { get; } + public string ErrorMessage { get; } + + public bool RequiresConversion => IsValid && Encoding != CsvSourceEncoding.Utf8; + + public string DisplayName + { + get + { + switch (Encoding) + { + case CsvSourceEncoding.Utf8: + return HasBom ? "UTF-8 (BOM)" : "UTF-8"; + case CsvSourceEncoding.ShiftJis: + return "Shift_JIS (CP932)"; + case CsvSourceEncoding.Utf16LittleEndian: + return "UTF-16 LE"; + case CsvSourceEncoding.Utf16BigEndian: + return "UTF-16 BE"; + case CsvSourceEncoding.Utf32LittleEndian: + return "UTF-32 LE"; + case CsvSourceEncoding.Utf32BigEndian: + return "UTF-32 BE"; + default: + return "Unknown"; + } + } + } + } + + /// + /// CSVの元バイト列を検査し、UTF-8へ変換します。 + /// + internal static class CsvEncodingUtility + { + private static readonly UTF8Encoding Utf8Strict = new UTF8Encoding(false, true); + private static readonly UnicodeEncoding Utf16LittleEndianStrict = + new UnicodeEncoding(false, true, true); + private static readonly UnicodeEncoding Utf16BigEndianStrict = + new UnicodeEncoding(true, true, true); + private static readonly UTF32Encoding Utf32LittleEndianStrict = + new UTF32Encoding(false, true, true); + private static readonly UTF32Encoding Utf32BigEndianStrict = + new UTF32Encoding(true, true, true); + + public static CsvEncodingInspection Inspect(byte[] bytes) + { + if (bytes == null) throw new ArgumentNullException(nameof(bytes)); + + if (HasPrefix(bytes, 0x00, 0x00, 0xFE, 0xFF)) + { + return Decode(bytes, CsvSourceEncoding.Utf32BigEndian, 4, true); + } + + if (HasPrefix(bytes, 0xFF, 0xFE, 0x00, 0x00)) + { + return Decode(bytes, CsvSourceEncoding.Utf32LittleEndian, 4, true); + } + + if (HasPrefix(bytes, 0xEF, 0xBB, 0xBF)) + { + return Decode(bytes, CsvSourceEncoding.Utf8, 3, true); + } + + if (HasPrefix(bytes, 0xFE, 0xFF)) + { + return Decode(bytes, CsvSourceEncoding.Utf16BigEndian, 2, true); + } + + if (HasPrefix(bytes, 0xFF, 0xFE)) + { + return Decode(bytes, CsvSourceEncoding.Utf16LittleEndian, 2, true); + } + + CsvEncodingInspection utf8 = Decode(bytes, CsvSourceEncoding.Utf8, 0, false); + if (utf8.IsValid) return utf8; + + CsvEncodingInspection shiftJis = Decode(bytes, CsvSourceEncoding.ShiftJis, 0, false); + if (shiftJis.IsValid) return shiftJis; + + return Invalid(CsvSourceEncoding.Auto, "UTF-8またはShift_JISとして解釈できません。"); + } + + public static CsvEncodingInspection Decode(byte[] bytes, CsvSourceEncoding encoding) + { + if (bytes == null) throw new ArgumentNullException(nameof(bytes)); + if (encoding == CsvSourceEncoding.Auto) return Inspect(bytes); + + int preambleLength = GetMatchingPreambleLength(bytes, encoding); + return Decode(bytes, encoding, preambleLength, preambleLength > 0); + } + + public static byte[] ConvertToUtf8(byte[] bytes, CsvSourceEncoding sourceEncoding) + { + CsvEncodingInspection inspection = Decode(bytes, sourceEncoding); + if (!inspection.IsValid) + { + throw new InvalidOperationException(inspection.ErrorMessage); + } + + return new UTF8Encoding(false).GetBytes(inspection.Text); + } + + private static CsvEncodingInspection Decode( + byte[] bytes, + CsvSourceEncoding encoding, + int offset, + bool hasBom) + { + // Unity/MonoのCP932デコーダーは、不完全な先行バイトを例外にしない場合がある。 + if (encoding == CsvSourceEncoding.ShiftJis && !IsValidShiftJis(bytes, offset)) + { + return Invalid(encoding, "Shift_JISのバイト列が不正です。"); + } + + try + { + Encoding decoder = GetEncoding(encoding); + string text = decoder.GetString(bytes, offset, bytes.Length - offset); + return new CsvEncodingInspection(encoding, hasBom, true, text, string.Empty); + } + catch (Exception exception) when ( + exception is DecoderFallbackException || + exception is ArgumentException || + exception is NotSupportedException) + { + return Invalid(encoding, exception.Message); + } + } + + private static bool IsValidShiftJis(byte[] bytes, int offset) + { + for (int i = offset; i < bytes.Length; i++) + { + byte current = bytes[i]; + bool isSingleByte = current <= 0x80 || + current == 0xA0 || + current >= 0xA1 && current <= 0xDF || + current >= 0xFD; + if (isSingleByte) + { + continue; + } + + bool isLeadByte = current >= 0x81 && current <= 0x9F || + current >= 0xE0 && current <= 0xFC; + if (!isLeadByte || i + 1 >= bytes.Length) return false; + + byte trail = bytes[++i]; + bool isTrailByte = trail >= 0x40 && trail <= 0x7E || + trail >= 0x80 && trail <= 0xFC; + if (!isTrailByte) return false; + } + + return true; + } + + private static Encoding GetEncoding(CsvSourceEncoding encoding) + { + switch (encoding) + { + case CsvSourceEncoding.Utf8: + return Utf8Strict; + case CsvSourceEncoding.ShiftJis: + return Encoding.GetEncoding( + 932, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + case CsvSourceEncoding.Utf16LittleEndian: + return Utf16LittleEndianStrict; + case CsvSourceEncoding.Utf16BigEndian: + return Utf16BigEndianStrict; + case CsvSourceEncoding.Utf32LittleEndian: + return Utf32LittleEndianStrict; + case CsvSourceEncoding.Utf32BigEndian: + return Utf32BigEndianStrict; + default: + throw new ArgumentOutOfRangeException(nameof(encoding), encoding, null); + } + } + + private static int GetMatchingPreambleLength(byte[] bytes, CsvSourceEncoding encoding) + { + switch (encoding) + { + case CsvSourceEncoding.Utf8: + return HasPrefix(bytes, 0xEF, 0xBB, 0xBF) ? 3 : 0; + case CsvSourceEncoding.Utf16LittleEndian: + return HasPrefix(bytes, 0xFF, 0xFE) ? 2 : 0; + case CsvSourceEncoding.Utf16BigEndian: + return HasPrefix(bytes, 0xFE, 0xFF) ? 2 : 0; + case CsvSourceEncoding.Utf32LittleEndian: + return HasPrefix(bytes, 0xFF, 0xFE, 0x00, 0x00) ? 4 : 0; + case CsvSourceEncoding.Utf32BigEndian: + return HasPrefix(bytes, 0x00, 0x00, 0xFE, 0xFF) ? 4 : 0; + default: + return 0; + } + } + + private static bool HasPrefix(byte[] bytes, params byte[] prefix) + { + if (bytes.Length < prefix.Length) return false; + + for (int i = 0; i < prefix.Length; i++) + { + if (bytes[i] != prefix[i]) return false; + } + + return true; + } + + private static CsvEncodingInspection Invalid(CsvSourceEncoding encoding, string message) + { + return new CsvEncodingInspection(encoding, false, false, null, message); + } + } +} +#endif diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs.meta new file mode 100644 index 0000000..25d09be --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38d547a81d6d4c9385ec13f73d7d5d2f diff --git a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs index 22f0a1e..eb97328 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs @@ -12,12 +12,32 @@ namespace CSV4Unity.Editor { /// - /// CSVのTextAssetに、Enumスキーマを使った検証UIを追加します。 + /// CSVのTextAssetに、文字コード変換、Viewer、ValidationのUIを追加します。 /// [CustomEditor(typeof(TextAsset))] public sealed class CsvInspectorEditor : UnityEditor.Editor { private const string FieldsNamespace = "CSV4Unity.Fields"; + private static readonly CsvSourceEncoding[] SourceEncodingValues = + { + CsvSourceEncoding.Auto, + CsvSourceEncoding.Utf8, + CsvSourceEncoding.ShiftJis, + CsvSourceEncoding.Utf16LittleEndian, + CsvSourceEncoding.Utf16BigEndian, + CsvSourceEncoding.Utf32LittleEndian, + CsvSourceEncoding.Utf32BigEndian + }; + private static readonly string[] SourceEncodingLabels = + { + "Auto Detect", + "UTF-8", + "Shift_JIS (CP932)", + "UTF-16 LE", + "UTF-16 BE", + "UTF-32 LE", + "UTF-32 BE" + }; private static readonly MethodInfo ValidateDocumentMethod = typeof(CsvInspectorEditor) .GetMethod(nameof(ValidateDocument), BindingFlags.NonPublic | BindingFlags.Static); @@ -28,17 +48,22 @@ public sealed class CsvInspectorEditor : UnityEditor.Editor private CsvValidationResult _validationResult; private Vector2 _scrollPosition; private bool _showValidationResults; + private bool _showEncodingPreview; private bool _isCsv; + private string _assetPath; + private CsvSourceEncoding _sourceEncoding; + private CsvEncodingInspection _encodingInspection; private void OnEnable() { _csvFile = target as TextAsset; - string assetPath = AssetDatabase.GetAssetPath(_csvFile); - _isCsv = string.Equals(Path.GetExtension(assetPath), ".csv", StringComparison.OrdinalIgnoreCase); + _assetPath = AssetDatabase.GetAssetPath(_csvFile); + _isCsv = string.Equals(Path.GetExtension(_assetPath), ".csv", StringComparison.OrdinalIgnoreCase); if (!_isCsv) return; + RefreshEncodingInspection(); RefreshEnums(); - RestoreSelection(assetPath); + RestoreSelection(_assetPath); } public override void OnInspectorGUI() @@ -51,7 +76,7 @@ public override void OnInspectorGUI() GUI.enabled = true; try { - DrawCsvValidationControls(); + DrawCsvControls(); } finally { @@ -59,12 +84,24 @@ public override void OnInspectorGUI() } } - private void DrawCsvValidationControls() + private void DrawCsvControls() { EditorGUILayout.Space(10); DrawSeparator(); EditorGUILayout.Space(5); + DrawEncodingControls(); + if (!IsUtf8Ready()) + { + EditorGUILayout.HelpBox( + "CSV ViewerとValidationを使用する前に、CSVをUTF-8へ変換してください。", + MessageType.Warning); + return; + } + + EditorGUILayout.Space(10); + DrawSeparator(); + EditorGUILayout.Space(5); EditorGUILayout.LabelField("CSV Viewer", EditorStyles.boldLabel); if (GUILayout.Button("Open CSV Viewer", GUILayout.Height(26))) { @@ -107,6 +144,157 @@ private void DrawCsvValidationControls() } } + private void DrawEncodingControls() + { + EditorGUILayout.LabelField("CSV Encoding", EditorStyles.boldLabel); + + if (!_encodingInspection.IsValid) + { + EditorGUILayout.HelpBox( + $"Encoding could not be detected. {_encodingInspection.ErrorMessage}", + MessageType.Error); + } + else if (_encodingInspection.RequiresConversion) + { + EditorGUILayout.HelpBox( + $"Detected {_encodingInspection.DisplayName}. Convert this file to UTF-8 before using it.", + MessageType.Warning); + } + else + { + EditorGUILayout.HelpBox( + $"Encoding: {_encodingInspection.DisplayName}", + MessageType.Info); + } + + int selectedIndex = Array.IndexOf(SourceEncodingValues, _sourceEncoding); + int nextIndex = EditorGUILayout.Popup( + "Source Encoding", + Math.Max(selectedIndex, 0), + SourceEncodingLabels); + CsvSourceEncoding selectedEncoding = SourceEncodingValues[nextIndex]; + if (selectedEncoding != _sourceEncoding) + { + _sourceEncoding = selectedEncoding; + InspectUsingSelectedEncoding(); + } + + if (_encodingInspection.IsValid) + { + _showEncodingPreview = EditorGUILayout.Foldout( + _showEncodingPreview, + "Decoded Preview", + true); + if (_showEncodingPreview) + { + EditorGUILayout.SelectableLabel( + CreatePreview(_encodingInspection.Text), + EditorStyles.textArea, + GUILayout.MinHeight(80), + GUILayout.MaxHeight(160)); + } + } + + using (new EditorGUI.DisabledScope(!_encodingInspection.RequiresConversion)) + { + if (GUILayout.Button("Convert to UTF-8", GUILayout.Height(26))) + { + ConvertAssetToUtf8(); + } + } + } + + private void RefreshEncodingInspection() + { + try + { + byte[] bytes = File.ReadAllBytes(GetAbsoluteAssetPath()); + _encodingInspection = CsvEncodingUtility.Inspect(bytes); + _sourceEncoding = _encodingInspection.IsValid + ? _encodingInspection.Encoding + : CsvSourceEncoding.Auto; + } + catch (Exception exception) + { + _encodingInspection = new CsvEncodingInspection( + CsvSourceEncoding.Auto, + false, + false, + null, + exception.Message); + _sourceEncoding = CsvSourceEncoding.Auto; + } + } + + private void InspectUsingSelectedEncoding() + { + try + { + byte[] bytes = File.ReadAllBytes(GetAbsoluteAssetPath()); + _encodingInspection = CsvEncodingUtility.Decode(bytes, _sourceEncoding); + } + catch (Exception exception) + { + _encodingInspection = new CsvEncodingInspection( + _sourceEncoding, + false, + false, + null, + exception.Message); + } + } + + private void ConvertAssetToUtf8() + { + if (!_encodingInspection.RequiresConversion) return; + + bool confirmed = EditorUtility.DisplayDialog( + "Convert CSV to UTF-8", + $"{_csvFile.name}.csv を {_encodingInspection.DisplayName} からUTF-8へ変換します。\n" + + "ファイル内容が更新され、Gitの変更対象になります。", + "Convert", + "Cancel"); + if (!confirmed) return; + + try + { + string absolutePath = GetAbsoluteAssetPath(); + byte[] source = File.ReadAllBytes(absolutePath); + byte[] utf8 = CsvEncodingUtility.ConvertToUtf8(source, _sourceEncoding); + File.WriteAllBytes(absolutePath, utf8); + AssetDatabase.ImportAsset(_assetPath, ImportAssetOptions.ForceUpdate); + _csvFile = AssetDatabase.LoadAssetAtPath(_assetPath); + RefreshEncodingInspection(); + _validationResult = null; + _showValidationResults = false; + Debug.Log($"CSV4Unity: Converted '{_assetPath}' to UTF-8.", _csvFile); + } + catch (Exception exception) + { + Debug.LogError($"CSV4Unity: Failed to convert '{_assetPath}' to UTF-8. {exception}", _csvFile); + EditorUtility.DisplayDialog("CSV Conversion Failed", exception.Message, "OK"); + RefreshEncodingInspection(); + } + } + + private bool IsUtf8Ready() + { + return _encodingInspection.IsValid && + _encodingInspection.Encoding == CsvSourceEncoding.Utf8; + } + + private string GetAbsoluteAssetPath() + { + return Path.GetFullPath(_assetPath); + } + + private static string CreatePreview(string text) + { + const int maxLength = 2000; + if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text ?? string.Empty; + return text.Substring(0, maxLength) + "\n..."; + } + private void DrawSchemaSelector() { string[] options = new string[_availableEnums.Count + 1]; From 3c02ae1b9179dec7fc1425bbb7196b2b9c5e2812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:36:53 +0900 Subject: [PATCH 13/28] =?UTF-8?q?test:=20CSV=E6=96=87=E5=AD=97=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E5=A4=89=E6=8F=9B=E3=81=AE=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/EditMode/CsvEncodingUtilityTests.cs | 123 ++++++++++++++++++ .../EditMode/CsvEncodingUtilityTests.cs.meta | 2 + 2 files changed, 125 insertions(+) create mode 100644 Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs create mode 100644 Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs.meta diff --git a/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs b/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs new file mode 100644 index 0000000..ed20856 --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs @@ -0,0 +1,123 @@ +using System.Text; +using CSV4Unity.Editor; +using NUnit.Framework; + +namespace CSV4Unity.Tests +{ + public sealed class CsvEncodingUtilityTests + { + private const string JapaneseCsv = "Id,名前\r\n1,太郎"; + + [Test] + public void Inspect_Utf8WithoutBom_ReturnsUtf8() + { + byte[] bytes = new UTF8Encoding(false).GetBytes(JapaneseCsv); + + CsvEncodingInspection result = CsvEncodingUtility.Inspect(bytes); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.Encoding, Is.EqualTo(CsvSourceEncoding.Utf8)); + Assert.That(result.HasBom, Is.False); + Assert.That(result.RequiresConversion, Is.False); + Assert.That(result.Text, Is.EqualTo(JapaneseCsv)); + } + + [Test] + public void Inspect_Utf8WithBom_RemovesBom() + { + var encoding = new UTF8Encoding(true); + byte[] bytes = Combine(encoding.GetPreamble(), encoding.GetBytes(JapaneseCsv)); + + CsvEncodingInspection result = CsvEncodingUtility.Inspect(bytes); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.Encoding, Is.EqualTo(CsvSourceEncoding.Utf8)); + Assert.That(result.HasBom, Is.True); + Assert.That(result.Text, Is.EqualTo(JapaneseCsv)); + } + + [Test] + public void Inspect_ShiftJis_ReturnsDecodedText() + { + Encoding encoding = Encoding.GetEncoding(932); + byte[] bytes = encoding.GetBytes(JapaneseCsv); + + CsvEncodingInspection result = CsvEncodingUtility.Inspect(bytes); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.Encoding, Is.EqualTo(CsvSourceEncoding.ShiftJis)); + Assert.That(result.RequiresConversion, Is.True); + Assert.That(result.Text, Is.EqualTo(JapaneseCsv)); + } + + [Test] + public void Inspect_Utf16Bom_ReturnsDecodedText() + { + var encoding = new UnicodeEncoding(false, true); + byte[] bytes = Combine(encoding.GetPreamble(), encoding.GetBytes(JapaneseCsv)); + + CsvEncodingInspection result = CsvEncodingUtility.Inspect(bytes); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.Encoding, Is.EqualTo(CsvSourceEncoding.Utf16LittleEndian)); + Assert.That(result.HasBom, Is.True); + Assert.That(result.Text, Is.EqualTo(JapaneseCsv)); + } + + [Test] + public void Decode_ExplicitUtf16WithoutBom_UsesSelectedEncoding() + { + byte[] bytes = new UnicodeEncoding(false, false).GetBytes(JapaneseCsv); + + CsvEncodingInspection result = CsvEncodingUtility.Decode( + bytes, + CsvSourceEncoding.Utf16LittleEndian); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.Encoding, Is.EqualTo(CsvSourceEncoding.Utf16LittleEndian)); + Assert.That(result.HasBom, Is.False); + Assert.That(result.Text, Is.EqualTo(JapaneseCsv)); + } + + [Test] + public void ConvertToUtf8_ShiftJis_PreservesTextAndLineEndings() + { + byte[] source = Encoding.GetEncoding(932).GetBytes(JapaneseCsv); + + byte[] converted = CsvEncodingUtility.ConvertToUtf8( + source, + CsvSourceEncoding.ShiftJis); + + Assert.That(HasUtf8Bom(converted), Is.False); + Assert.That(new UTF8Encoding(false, true).GetString(converted), Is.EqualTo(JapaneseCsv)); + } + + [Test] + public void Inspect_InvalidByteSequence_ReturnsInvalidResult() + { + byte[] bytes = { 0x81 }; + + CsvEncodingInspection result = CsvEncodingUtility.Inspect(bytes); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.Encoding, Is.EqualTo(CsvSourceEncoding.Auto)); + Assert.That(result.ErrorMessage, Is.Not.Empty); + } + + private static byte[] Combine(byte[] first, byte[] second) + { + var result = new byte[first.Length + second.Length]; + first.CopyTo(result, 0); + second.CopyTo(result, first.Length); + return result; + } + + private static bool HasUtf8Bom(byte[] bytes) + { + return bytes.Length >= 3 && + bytes[0] == 0xEF && + bytes[1] == 0xBB && + bytes[2] == 0xBF; + } + } +} diff --git a/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs.meta b/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs.meta new file mode 100644 index 0000000..1e2ccc8 --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4df118ef46c144b6b8b054d7a1a7a38f From 24acfedb4918d72c07f4475a012bd1338a9e3194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:37:01 +0900 Subject: [PATCH 14/28] =?UTF-8?q?docs:=20CSV=E6=96=87=E5=AD=97=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E5=A4=89=E6=8F=9B=E3=81=AE=E5=88=A9=E7=94=A8?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +++++++++ docs/en/architecture.md | 5 ++++- docs/ja/architecture.md | 5 ++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9bfe922..a52a554 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ CSVをクラスへ一括変換せず、セルを必要なときに指定した - 検索用インデックスの明示生成 - Enum属性によるValidation - CSV Inspectorからの手動Validation +- CSV Inspectorでの文字コード検査とUTF-8変換 - 読み取り専用CSV Viewer ## インストール @@ -243,6 +244,14 @@ Text Conditionは上から順に実行される`if / else`ではありません。各グループは独立して評価されるため、条件が重なると複数のValidationが同時に適用されます。else相当は`NotIn`や`NotEqual`で明示してください。 +## CSV Encoding + +RuntimeのCSV読み込みはUTF-8を前提とします。ProjectウィンドウでCSVを選択すると、Inspectorの `CSV Encoding` に元ファイルの判定結果とデコード後のプレビューが表示されます。 + +Shift_JIS、UTF-16、UTF-32のCSVは、内容を確認してから `Convert to UTF-8` を押してください。変換結果はUTF-8(BOMなし)で元のCSVへ保存され、Gitの変更対象になります。自動判定が正しくない場合は `Source Encoding` で変換元を明示できます。 + +CSV4Unityはインポート時にファイルを自動変換しません。変換前のCSVでは文字化けを避けるため、ViewerとInspector Validationを実行できません。 + ## CSV Viewer ProjectウィンドウでCSVを選択し、Inspectorの `Open CSV Viewer` を押すと、CSVを読み取り専用の表として確認できます。CSVを右クリックして `Open in CSV Viewer` を選ぶか、`Window > CSV4Unity > CSV Viewer` から開くこともできます。 diff --git a/docs/en/architecture.md b/docs/en/architecture.md index 3724c7d..9f224be 100644 --- a/docs/en/architecture.md +++ b/docs/en/architecture.md @@ -123,12 +123,15 @@ Responsibility: adapt Unity inputs to the pure C# core. ### Unity Editor tools -- `CsvInspectorEditor` adds the viewer and validation entry points to CSV assets. +- `CsvInspectorEditor` provides source-encoding inspection, explicit UTF-8 conversion, and the viewer and validation entry points. +- `CsvEncodingUtility` validates source bytes and converts a selected source encoding to UTF-8. - `CsvViewerWindow` owns asset selection, parsing, search state, and reload behavior. - `CsvViewerTable` draws only visible rows and provides column resizing and copy commands. The viewer treats `CsvDocument` as read-only data. It caches display strings for at most 256 rows instead of duplicating the complete CSV as a two-dimensional string array. Editing and writing remain separate future responsibilities. +Encoding inspection reads the original CSV bytes instead of `TextAsset.text`. BOMs take precedence; files without a BOM are checked as strict UTF-8 and then Shift_JIS (CP932). The Inspector allows an explicit source-encoding override. Conversion is always a confirmed manual action and writes UTF-8 without a BOM; importing an asset never rewrites it automatically. + ## Validation boundary `CsvValidationSchema` compiles attribute metadata once and `CsvValidator` applies the resulting rules to `CsvTable`. Row-local rules and column/table rules remain separate: diff --git a/docs/ja/architecture.md b/docs/ja/architecture.md index e359623..39fadbd 100644 --- a/docs/ja/architecture.md +++ b/docs/ja/architecture.md @@ -151,12 +151,15 @@ public enum ItemField | 型 | 役割 | |---|---| -| `CsvInspectorEditor` | CSV InspectorへViewerとValidationの入口を追加する | +| `CsvInspectorEditor` | CSVの文字コード確認・UTF-8変換と、Viewer・Validationの入口を提供する | +| `CsvEncodingUtility` | 元バイト列の文字コードを検査し、明示された文字コードからUTF-8へ変換する | | `CsvViewerWindow` | CSVアセットの選択、解析、検索条件、再読込を管理する | | `CsvViewerTable` | 表示範囲の行だけを描画し、列幅変更とコピー操作を提供する | ViewerはEditor専用であり、`CsvDocument`を読み取り専用データとして利用します。表示用文字列は最大256行分だけキャッシュし、CSV全体を表示専用の二次元文字列配列へ複製しません。編集や書き出しは別の責務とします。 +文字コード検査はUnityが生成した`TextAsset.text`ではなく、プロジェクト内の元CSVファイルをバイト列として読み取ります。BOMを優先し、BOMなしは厳密なUTF-8、次にShift_JIS(CP932)として検査します。誤判定時はInspectorで変換元を指定できます。ファイルの自動書き換えは行わず、確認ダイアログを伴う手動操作だけでUTF-8(BOMなし)へ変換します。 + ### 型変換 | 型 | 役割 | From cace6f9a9d233169e7789dcdd6579aa7ece7def6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:16:12 +0900 Subject: [PATCH 15/28] =?UTF-8?q?fix:=20CSV=E6=96=87=E5=AD=97=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=AE=E8=AA=A4=E5=A4=89=E6=8F=9B=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=BE=A9=E5=85=83=E5=8F=AF=E8=83=BD=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Editor/CsvEncodingBackupUtility.cs | 63 ++++++++ .../Editor/CsvEncodingBackupUtility.cs.meta | 2 + .../CSVLoader/Editor/CsvInspectorEditor.cs | 152 +++++++++++++++--- 3 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs.meta diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs b/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs new file mode 100644 index 0000000..f725ad7 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs @@ -0,0 +1,63 @@ +#if UNITY_EDITOR +using System; +using System.IO; + +namespace CSV4Unity.Editor +{ + /// + /// 文字コード変換前のCSVをLibrary以下へ退避し、必要に応じて復元します。 + /// + internal static class CsvEncodingBackupUtility + { + public static bool CreateIfMissing(string backupPath, byte[] source) + { + if (string.IsNullOrEmpty(backupPath)) throw new ArgumentException("Backup path is required.", nameof(backupPath)); + if (source == null) throw new ArgumentNullException(nameof(source)); + if (File.Exists(backupPath)) return false; + + string directory = Path.GetDirectoryName(backupPath); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + WriteAtomically(backupPath, source); + return true; + } + + public static void Restore(string backupPath, string targetPath) + { + if (string.IsNullOrEmpty(backupPath)) throw new ArgumentException("Backup path is required.", nameof(backupPath)); + if (string.IsNullOrEmpty(targetPath)) throw new ArgumentException("Target path is required.", nameof(targetPath)); + if (!File.Exists(backupPath)) throw new FileNotFoundException("CSV encoding backup was not found.", backupPath); + + byte[] original = File.ReadAllBytes(backupPath); + WriteAtomically(targetPath, original); + File.Delete(backupPath); + } + + public static void WriteAtomically(string targetPath, byte[] bytes) + { + if (string.IsNullOrEmpty(targetPath)) throw new ArgumentException("Target path is required.", nameof(targetPath)); + if (bytes == null) throw new ArgumentNullException(nameof(bytes)); + + string directory = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + + string temporaryPath = targetPath + ".csv4unity-" + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllBytes(temporaryPath, bytes); + if (File.Exists(targetPath)) + { + File.Replace(temporaryPath, targetPath, null); + } + else + { + File.Move(temporaryPath, targetPath); + } + } + finally + { + if (File.Exists(temporaryPath)) File.Delete(temporaryPath); + } + } + } +} +#endif diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs.meta new file mode 100644 index 0000000..15dad54 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 58d6e38a87e14df9bf8dcebed8275fe1 diff --git a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs index eb97328..b72a8d8 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs @@ -49,9 +49,11 @@ public sealed class CsvInspectorEditor : UnityEditor.Editor private Vector2 _scrollPosition; private bool _showValidationResults; private bool _showEncodingPreview; + private bool _overrideEncodingDetection; private bool _isCsv; private string _assetPath; private CsvSourceEncoding _sourceEncoding; + private CsvEncodingInspection _automaticEncodingInspection; private CsvEncodingInspection _encodingInspection; private void OnEnable() @@ -148,35 +150,69 @@ private void DrawEncodingControls() { EditorGUILayout.LabelField("CSV Encoding", EditorStyles.boldLabel); - if (!_encodingInspection.IsValid) + if (!_automaticEncodingInspection.IsValid) { EditorGUILayout.HelpBox( - $"Encoding could not be detected. {_encodingInspection.ErrorMessage}", + $"Encoding could not be detected. {_automaticEncodingInspection.ErrorMessage}", MessageType.Error); } - else if (_encodingInspection.RequiresConversion) + else if (_automaticEncodingInspection.RequiresConversion) { EditorGUILayout.HelpBox( - $"Detected {_encodingInspection.DisplayName}. Convert this file to UTF-8 before using it.", + $"Detected {_automaticEncodingInspection.DisplayName}. Convert this file to UTF-8 before using it.", MessageType.Warning); } else { EditorGUILayout.HelpBox( - $"Encoding: {_encodingInspection.DisplayName}", + $"Encoding: {_automaticEncodingInspection.DisplayName}", MessageType.Info); } - int selectedIndex = Array.IndexOf(SourceEncodingValues, _sourceEncoding); - int nextIndex = EditorGUILayout.Popup( - "Source Encoding", - Math.Max(selectedIndex, 0), - SourceEncodingLabels); - CsvSourceEncoding selectedEncoding = SourceEncodingValues[nextIndex]; - if (selectedEncoding != _sourceEncoding) + bool overrideDetection = EditorGUILayout.ToggleLeft( + "Override automatic detection", + _overrideEncodingDetection); + if (overrideDetection != _overrideEncodingDetection) { - _sourceEncoding = selectedEncoding; - InspectUsingSelectedEncoding(); + _overrideEncodingDetection = overrideDetection; + if (_overrideEncodingDetection) + { + InspectUsingSelectedEncoding(); + _showEncodingPreview = true; + } + else + { + _encodingInspection = _automaticEncodingInspection; + } + } + + if (_overrideEncodingDetection) + { + int selectedIndex = Array.IndexOf(SourceEncodingValues, _sourceEncoding); + int nextIndex = EditorGUILayout.Popup( + "Source Encoding", + Math.Max(selectedIndex, 0), + SourceEncodingLabels); + CsvSourceEncoding selectedEncoding = SourceEncodingValues[nextIndex]; + if (selectedEncoding != _sourceEncoding) + { + _sourceEncoding = selectedEncoding; + InspectUsingSelectedEncoding(); + _showEncodingPreview = true; + } + + if (IsOverrideDifferentFromDetection()) + { + EditorGUILayout.HelpBox( + $"Selected {_encodingInspection.DisplayName}, but automatic detection found " + + $"{_automaticEncodingInspection.DisplayName}. Check the preview carefully before converting.", + MessageType.Error); + } + + if (!_encodingInspection.IsValid) + { + EditorGUILayout.HelpBox(_encodingInspection.ErrorMessage, MessageType.Error); + } } if (_encodingInspection.IsValid) @@ -197,11 +233,15 @@ private void DrawEncodingControls() using (new EditorGUI.DisabledScope(!_encodingInspection.RequiresConversion)) { - if (GUILayout.Button("Convert to UTF-8", GUILayout.Height(26))) + if (GUILayout.Button( + $"Convert {_encodingInspection.DisplayName} to UTF-8", + GUILayout.Height(26))) { ConvertAssetToUtf8(); } } + + DrawEncodingBackupControls(); } private void RefreshEncodingInspection() @@ -209,20 +249,25 @@ private void RefreshEncodingInspection() try { byte[] bytes = File.ReadAllBytes(GetAbsoluteAssetPath()); - _encodingInspection = CsvEncodingUtility.Inspect(bytes); - _sourceEncoding = _encodingInspection.IsValid - ? _encodingInspection.Encoding + _automaticEncodingInspection = CsvEncodingUtility.Inspect(bytes); + _encodingInspection = _automaticEncodingInspection; + _sourceEncoding = _automaticEncodingInspection.IsValid + ? _automaticEncodingInspection.Encoding : CsvSourceEncoding.Auto; + _overrideEncodingDetection = !_automaticEncodingInspection.IsValid; + _showEncodingPreview = _encodingInspection.RequiresConversion; } catch (Exception exception) { - _encodingInspection = new CsvEncodingInspection( + _automaticEncodingInspection = new CsvEncodingInspection( CsvSourceEncoding.Auto, false, false, null, exception.Message); + _encodingInspection = _automaticEncodingInspection; _sourceEncoding = CsvSourceEncoding.Auto; + _overrideEncodingDetection = true; } } @@ -251,7 +296,10 @@ private void ConvertAssetToUtf8() bool confirmed = EditorUtility.DisplayDialog( "Convert CSV to UTF-8", $"{_csvFile.name}.csv を {_encodingInspection.DisplayName} からUTF-8へ変換します。\n" + - "ファイル内容が更新され、Gitの変更対象になります。", + "ファイル内容が更新され、Gitの変更対象になります。" + + (IsOverrideDifferentFromDetection() + ? $"\n\n警告: 自動判定は {_automaticEncodingInspection.DisplayName} です。" + : string.Empty), "Convert", "Cancel"); if (!confirmed) return; @@ -260,8 +308,12 @@ private void ConvertAssetToUtf8() { string absolutePath = GetAbsoluteAssetPath(); byte[] source = File.ReadAllBytes(absolutePath); - byte[] utf8 = CsvEncodingUtility.ConvertToUtf8(source, _sourceEncoding); - File.WriteAllBytes(absolutePath, utf8); + CsvSourceEncoding sourceEncoding = _overrideEncodingDetection + ? _sourceEncoding + : CsvSourceEncoding.Auto; + byte[] utf8 = CsvEncodingUtility.ConvertToUtf8(source, sourceEncoding); + CsvEncodingBackupUtility.CreateIfMissing(GetBackupPath(), source); + CsvEncodingBackupUtility.WriteAtomically(absolutePath, utf8); AssetDatabase.ImportAsset(_assetPath, ImportAssetOptions.ForceUpdate); _csvFile = AssetDatabase.LoadAssetAtPath(_assetPath); RefreshEncodingInspection(); @@ -277,6 +329,50 @@ private void ConvertAssetToUtf8() } } + private void DrawEncodingBackupControls() + { + if (!File.Exists(GetBackupPath())) return; + + EditorGUILayout.HelpBox( + "The original bytes from before the first conversion are available as a backup.", + MessageType.Info); + if (GUILayout.Button("Restore Pre-conversion File")) RestoreEncodingBackup(); + } + + private void RestoreEncodingBackup() + { + bool confirmed = EditorUtility.DisplayDialog( + "Restore CSV Before Conversion", + $"{_csvFile.name}.csv を最初の文字コード変換前の状態へ戻します。", + "Restore", + "Cancel"); + if (!confirmed) return; + + try + { + CsvEncodingBackupUtility.Restore(GetBackupPath(), GetAbsoluteAssetPath()); + AssetDatabase.ImportAsset(_assetPath, ImportAssetOptions.ForceUpdate); + _csvFile = AssetDatabase.LoadAssetAtPath(_assetPath); + RefreshEncodingInspection(); + _validationResult = null; + _showValidationResults = false; + Debug.Log($"CSV4Unity: Restored the pre-conversion file for '{_assetPath}'.", _csvFile); + } + catch (Exception exception) + { + Debug.LogError($"CSV4Unity: Failed to restore '{_assetPath}'. {exception}", _csvFile); + EditorUtility.DisplayDialog("CSV Restore Failed", exception.Message, "OK"); + } + } + + private bool IsOverrideDifferentFromDetection() + { + return _overrideEncodingDetection && + _automaticEncodingInspection.IsValid && + _sourceEncoding != CsvSourceEncoding.Auto && + _sourceEncoding != _automaticEncodingInspection.Encoding; + } + private bool IsUtf8Ready() { return _encodingInspection.IsValid && @@ -288,6 +384,18 @@ private string GetAbsoluteAssetPath() return Path.GetFullPath(_assetPath); } + private string GetBackupPath() + { + string projectRoot = Directory.GetParent(Application.dataPath)?.FullName; + string assetGuid = AssetDatabase.AssetPathToGUID(_assetPath); + return Path.Combine( + projectRoot ?? Path.GetFullPath("."), + "Library", + "CSV4Unity", + "EncodingBackups", + assetGuid + ".bytes"); + } + private static string CreatePreview(string text) { const int maxLength = 2000; From af7e43376e60c1ae832ec71d3adc28611a477ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:21:38 +0900 Subject: [PATCH 16/28] =?UTF-8?q?test:=20CSV=E6=96=87=E5=AD=97=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E5=A4=89=E6=8F=9B=E3=81=AE=E5=BE=A9=E5=85=83?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=82=92=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/EditMode/CsvEncodingUtilityTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs b/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs index ed20856..c65cefc 100644 --- a/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs +++ b/Assets/Scripts/Tests/EditMode/CsvEncodingUtilityTests.cs @@ -1,3 +1,5 @@ +using System; +using System.IO; using System.Text; using CSV4Unity.Editor; using NUnit.Framework; @@ -104,6 +106,54 @@ public void Inspect_InvalidByteSequence_ReturnsInvalidResult() Assert.That(result.ErrorMessage, Is.Not.Empty); } + [Test] + public void CreateBackup_ExistingBackupIsNotOverwritten() + { + string directory = CreateTemporaryDirectory(); + string backupPath = Path.Combine(directory, "source.bytes"); + byte[] original = { 1, 2, 3 }; + + try + { + bool created = CsvEncodingBackupUtility.CreateIfMissing(backupPath, original); + bool createdAgain = CsvEncodingBackupUtility.CreateIfMissing( + backupPath, + new byte[] { 9, 9, 9 }); + + Assert.That(created, Is.True); + Assert.That(createdAgain, Is.False); + Assert.That(File.ReadAllBytes(backupPath), Is.EqualTo(original)); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Test] + public void RestoreBackup_ReplacesTargetAndConsumesBackup() + { + string directory = CreateTemporaryDirectory(); + string backupPath = Path.Combine(directory, "source.bytes"); + string targetPath = Path.Combine(directory, "target.csv"); + byte[] original = { 1, 2, 3 }; + + try + { + CsvEncodingBackupUtility.CreateIfMissing(backupPath, original); + File.WriteAllBytes(targetPath, new byte[] { 9, 9, 9 }); + + CsvEncodingBackupUtility.Restore(backupPath, targetPath); + + Assert.That(File.ReadAllBytes(targetPath), Is.EqualTo(original)); + Assert.That(File.Exists(backupPath), Is.False); + } + finally + { + Directory.Delete(directory, true); + } + } + private static byte[] Combine(byte[] first, byte[] second) { var result = new byte[first.Length + second.Length]; @@ -119,5 +169,12 @@ private static bool HasUtf8Bom(byte[] bytes) bytes[1] == 0xBB && bytes[2] == 0xBF; } + + private static string CreateTemporaryDirectory() + { + string path = Path.Combine(Path.GetTempPath(), "CSV4Unity-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } } } From 7e1e81bc56e557419be1ee74da11af5d29c9ed25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:21:48 +0900 Subject: [PATCH 17/28] =?UTF-8?q?test:=20=E6=96=87=E5=AD=97=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E6=A4=9C=E5=87=BA=E7=94=A8CSV=20fixture?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 6 ++++++ Assets/TestData/CSV4Unity/EncodingDetection.meta | 8 ++++++++ .../EncodingDetection/EncodingInvalid.csv | 2 ++ .../EncodingDetection/EncodingInvalid.csv.meta | 7 +++++++ .../EncodingDetection/EncodingShiftJis.csv | 3 +++ .../EncodingDetection/EncodingShiftJis.csv.meta | 7 +++++++ .../EncodingDetection/EncodingUtf16BeBom.csv | Bin 0 -> 86 bytes .../EncodingDetection/EncodingUtf16BeBom.csv.meta | 7 +++++++ .../EncodingDetection/EncodingUtf16LeBom.csv | Bin 0 -> 86 bytes .../EncodingDetection/EncodingUtf16LeBom.csv.meta | 7 +++++++ .../EncodingDetection/EncodingUtf32BeBom.csv | Bin 0 -> 172 bytes .../EncodingDetection/EncodingUtf32BeBom.csv.meta | 7 +++++++ .../EncodingDetection/EncodingUtf32LeBom.csv | Bin 0 -> 172 bytes .../EncodingDetection/EncodingUtf32LeBom.csv.meta | 7 +++++++ .../CSV4Unity/EncodingDetection/EncodingUtf8.csv | 3 +++ .../EncodingDetection/EncodingUtf8.csv.meta | 7 +++++++ .../EncodingDetection/EncodingUtf8Bom.csv | 3 +++ .../EncodingDetection/EncodingUtf8Bom.csv.meta | 7 +++++++ 18 files changed, 81 insertions(+) create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection.meta create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv.meta create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv.meta create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16BeBom.csv create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16BeBom.csv.meta create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16LeBom.csv create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16LeBom.csv.meta create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32BeBom.csv create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32BeBom.csv.meta create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32LeBom.csv create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32LeBom.csv.meta create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf8.csv create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf8.csv.meta create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf8Bom.csv create mode 100644 Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf8Bom.csv.meta diff --git a/.gitattributes b/.gitattributes index 279b318..f7321f3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -30,5 +30,11 @@ *.sh text eol=lf *.ps1 text eol=lf +# Preserve the original bytes of encoding detection fixtures +Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv binary +Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16*.csv binary +Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32*.csv binary +Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv binary + # Exclude TextMesh Pro files from language statistics "Assets/TextMesh Pro/**" linguist-generated=true linguist-vendored=true diff --git a/Assets/TestData/CSV4Unity/EncodingDetection.meta b/Assets/TestData/CSV4Unity/EncodingDetection.meta new file mode 100644 index 0000000..9b1d69e --- /dev/null +++ b/Assets/TestData/CSV4Unity/EncodingDetection.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 35f3aa9ae25e93344a4fa5191ab553b2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv new file mode 100644 index 0000000..d4e0f2c --- /dev/null +++ b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv @@ -0,0 +1,2 @@ +Id,Name +1, \ No newline at end of file diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv.meta b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv.meta new file mode 100644 index 0000000..87f61ff --- /dev/null +++ b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 39998000949d52c44be9a5a38edd59a1 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv new file mode 100644 index 0000000..2aa143e --- /dev/null +++ b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv @@ -0,0 +1,3 @@ +Id,Name,Message +1,Y,ɂ +2,Ԏq,"J}," \ No newline at end of file diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv.meta b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv.meta new file mode 100644 index 0000000..e805571 --- /dev/null +++ b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e1ceacf93f8d8ee43b620c012e9e3d00 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16BeBom.csv b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16BeBom.csv new file mode 100644 index 0000000000000000000000000000000000000000..4f89d8f05ac303cfbad5d04fc3cd5a07ff8a2879 GIT binary patch literal 86 zcmezOpTUzMg+YhGk0FsEmmw9%@&%H`K$yso4rK8%a4{G%=tOEwILDx45Nt5nAlo3( fARjDl#Guo(F**RKN6BEd!DoYeKow2~T?|S9O{f$C literal 0 HcmV?d00001 diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16BeBom.csv.meta b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16BeBom.csv.meta new file mode 100644 index 0000000..17c89f2 --- /dev/null +++ b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16BeBom.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c3892e517a15a6342a0ed1a98eea19af +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16LeBom.csv b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16LeBom.csv new file mode 100644 index 0000000000000000000000000000000000000000..3d1522704966c3d6cbc3e73e5e065680c809c9ec GIT binary patch literal 86 zcmezW&yyjAL5IPQA(0`MAr;8-1(L-;n8=V0Wbra^F&F|xwIa_=&|wHRm~4=3kZ6!^ g01`I>if?QRh}L0HVpwhP+2Edm4ug|Hmw^%k09auZ0ssI2 literal 0 HcmV?d00001 diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16LeBom.csv.meta b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16LeBom.csv.meta new file mode 100644 index 0000000..d154704 --- /dev/null +++ b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16LeBom.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c80c6ce7af86f9447947126fcc6517b6 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32BeBom.csv b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32BeBom.csv new file mode 100644 index 0000000000000000000000000000000000000000..3fc09e3065e2d9c0be79412d64142b4bbbfee89a GIT binary patch literal 172 zcmZQz`1hZIfx#1qQ-D|pi2Z;#5r}hvI2DLNV!lvzF^~pfkbF8+j2B3A0kI(v1Jy=q zF)&Ox2VoloGcXuT2I6cW2I?`$2a3bg8$r}GZDe4G4uG&3lz?hh1Mz1dz6UkK3CQjO GvXuaWs}uqN literal 0 HcmV?d00001 diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32BeBom.csv.meta b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32BeBom.csv.meta new file mode 100644 index 0000000..327dc8a --- /dev/null +++ b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32BeBom.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 37a12c1ab78070c4197dd5dfb0b22369 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32LeBom.csv b/Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32LeBom.csv new file mode 100644 index 0000000000000000000000000000000000000000..437aec62f0c9345b05b7aa8b0dadf92bc111f3ee GIT binary patch literal 172 zcmezWkAcCHfq@|fh;@M24~P?iI2VXhffyv_3uPArX%Gg Date: Thu, 23 Jul 2026 03:11:00 +0900 Subject: [PATCH 18/28] =?UTF-8?q?feat:=20CsvSchema=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E3=81=AB=E3=82=88=E3=82=8BInspector=E7=99=BB=E9=8C=B2=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CSVLoader/Editor/CsvInspectorEditor.cs | 57 +++++----------- .../Editor/CsvSchemaTypeDiscovery.cs | 68 +++++++++++++++++++ .../Editor/CsvSchemaTypeDiscovery.cs.meta | 2 + .../Runtime/Schema/CsvSchemaAttribute.cs | 16 +++++ .../Runtime/Schema/CsvSchemaAttribute.cs.meta | 2 + 5 files changed, 105 insertions(+), 40 deletions(-) create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs.meta create mode 100644 Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaAttribute.cs create mode 100644 Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaAttribute.cs.meta diff --git a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs index b72a8d8..9ca01b0 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs @@ -17,7 +17,6 @@ namespace CSV4Unity.Editor [CustomEditor(typeof(TextAsset))] public sealed class CsvInspectorEditor : UnityEditor.Editor { - private const string FieldsNamespace = "CSV4Unity.Fields"; private static readonly CsvSourceEncoding[] SourceEncodingValues = { CsvSourceEncoding.Auto, @@ -118,7 +117,7 @@ private void DrawCsvControls() if (_availableEnums.Count == 0) { EditorGUILayout.HelpBox( - $"{FieldsNamespace} 名前空間にEnumが見つかりません。", + "CSVスキーマが見つかりません。Enumに[CsvSchema]を付けてください。", MessageType.Info); if (GUILayout.Button("Refresh Enums")) RefreshEnums(); @@ -415,13 +414,22 @@ private void DrawSchemaSelector() int popupIndex = EditorGUILayout.Popup("Validation Schema", _selectedEnumIndex + 1, options); int enumIndex = popupIndex - 1; - if (enumIndex == _selectedEnumIndex) return; + if (enumIndex != _selectedEnumIndex) + { + _selectedEnumIndex = enumIndex; + _selectedEnumType = enumIndex >= 0 ? _availableEnums[enumIndex] : null; + _validationResult = null; + _showValidationResults = false; + SaveSelection(); + } - _selectedEnumIndex = enumIndex; - _selectedEnumType = enumIndex >= 0 ? _availableEnums[enumIndex] : null; - _validationResult = null; - _showValidationResults = false; - SaveSelection(); + if (_selectedEnumType != null && CsvSchemaTypeDiscovery.IsLegacySchema(_selectedEnumType)) + { + EditorGUILayout.HelpBox( + "このスキーマは旧CSV4Unity.Fields名前空間規約で検出されています。" + + "Enumに[CsvSchema]を付けると、任意の名前空間へ配置できます。", + MessageType.Info); + } } private void DrawConstraints(Type enumType) @@ -656,38 +664,7 @@ private void CopyReportToClipboard() private void RefreshEnums() { _availableEnums.Clear(); - - Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); - for (int i = 0; i < assemblies.Length; i++) - { - foreach (Type type in GetLoadableTypes(assemblies[i])) - { - if (type.IsEnum && type.Namespace != null && - type.Namespace.StartsWith(FieldsNamespace, StringComparison.Ordinal)) - { - _availableEnums.Add(type); - } - } - } - - _availableEnums.Sort((left, right) => - string.Compare(left.FullName, right.FullName, StringComparison.Ordinal)); - } - - private static IEnumerable GetLoadableTypes(Assembly assembly) - { - try - { - return assembly.GetTypes(); - } - catch (ReflectionTypeLoadException exception) - { - return exception.Types.Where(type => type != null); - } - catch - { - return Array.Empty(); - } + _availableEnums.AddRange(CsvSchemaTypeDiscovery.FindAll()); } private void RestoreSelection(string assetPath) diff --git a/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs b/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs new file mode 100644 index 0000000..0a17b8e --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs @@ -0,0 +1,68 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEditor; + +namespace CSV4Unity.Editor +{ + /// + /// Inspectorで選択可能なCSVスキーマを収集します。 + /// + internal static class CsvSchemaTypeDiscovery + { + internal const string LegacyFieldsNamespace = "CSV4Unity.Fields"; + + internal static List FindAll() + { + var schemas = new HashSet(); + + foreach (Type type in TypeCache.GetTypesWithAttribute()) + { + if (type.IsEnum) schemas.Add(type); + } + + // v0.xとの互換性を保つため、旧名前空間規約も候補へ含める。 + Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); + for (int i = 0; i < assemblies.Length; i++) + { + foreach (Type type in GetLoadableTypes(assemblies[i])) + { + if (IsLegacySchema(type)) schemas.Add(type); + } + } + + return schemas + .OrderBy(type => type.FullName ?? type.Name, StringComparer.Ordinal) + .ToList(); + } + + internal static bool IsLegacySchema(Type type) + { + return type != null && + type.IsEnum && + !type.IsDefined(typeof(CsvSchemaAttribute), false) && + type.Namespace != null && + (type.Namespace == LegacyFieldsNamespace || + type.Namespace.StartsWith(LegacyFieldsNamespace + ".", StringComparison.Ordinal)); + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException exception) + { + return exception.Types.Where(type => type != null); + } + catch + { + return Array.Empty(); + } + } + } +} +#endif diff --git a/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs.meta new file mode 100644 index 0000000..f22b6f1 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 71dacd291c014d2e9788a4b45242bf1e diff --git a/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaAttribute.cs b/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaAttribute.cs new file mode 100644 index 0000000..3d7d411 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace CSV4Unity +{ + /// + /// EnumをCSVスキーマとしてUnity Editorへ登録します。 + /// + /// + /// この属性はInspectorのスキーマ候補を発見するために使用します。 + /// などのRuntime APIでは必須ではありません。 + /// + [AttributeUsage(AttributeTargets.Enum, AllowMultiple = false, Inherited = false)] + public sealed class CsvSchemaAttribute : Attribute + { + } +} diff --git a/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaAttribute.cs.meta b/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaAttribute.cs.meta new file mode 100644 index 0000000..49766f3 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Runtime/Schema/CsvSchemaAttribute.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7a2da920f6b941208415a3399c42171d From 9a3ec96bbf647adb11e0c78658025ab4e8786052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:11:12 +0900 Subject: [PATCH 19/28] =?UTF-8?q?refactor:=20Example=E3=81=A8=E6=89=8B?= =?UTF-8?q?=E5=8B=95=E7=A2=BA=E8=AA=8D=E7=94=A8=E3=82=B9=E3=82=AD=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E3=82=92=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Scripts/Examples/CsvUsageExamples.cs | 15 --------------- Assets/Scripts/Examples/ScenarioFields.cs | 3 ++- Assets/Scripts/Tests/Manual/CsvCoreManualCheck.cs | 2 +- .../Scripts/Tests/Manual/CsvManualCheckFields.cs | 6 +++++- .../{Examples => Tests/Manual}/HugeDataFields.cs | 5 +++-- .../Manual}/HugeDataFields.cs.meta | 0 6 files changed, 11 insertions(+), 20 deletions(-) rename Assets/Scripts/{Examples => Tests/Manual}/HugeDataFields.cs (57%) rename Assets/Scripts/{Examples => Tests/Manual}/HugeDataFields.cs.meta (100%) diff --git a/Assets/Scripts/Examples/CsvUsageExamples.cs b/Assets/Scripts/Examples/CsvUsageExamples.cs index 0e2b8e6..01f23ac 100644 --- a/Assets/Scripts/Examples/CsvUsageExamples.cs +++ b/Assets/Scripts/Examples/CsvUsageExamples.cs @@ -1,4 +1,3 @@ -using CSV4Unity.Fields; using UnityEngine; namespace CSV4Unity.Examples @@ -13,10 +12,6 @@ public sealed class CsvUsageExamples : MonoBehaviour [Tooltip("Assets/TestData/CSV4Unity/Scenario.csv を指定してください")] private TextAsset scenarioCsv; - [SerializeField] - [Tooltip("Assets/TestData/CSV4Unity/HugeData.csv を指定してください。未設定でも他の例は動作します")] - private TextAsset hugeDataCsv; - private void Start() { RunAllExamples(); @@ -39,7 +34,6 @@ public void RunAllExamples() LogHeaderNameAccess(); LogHeaderlessAccess(); LogRfc4180Access(); - LogLargeCsvAccess(); } private static void LogEnumRowAccess(CsvTable table) @@ -120,14 +114,5 @@ private static void LogRfc4180Access() Debug.Log($"[RFC 4180] Text={multiline}, Note={document.Cell(0, "Note").GetString()}"); } - - private void LogLargeCsvAccess() - { - if (hugeDataCsv == null) return; - - CsvTable table = CSVLoader.LoadTable(hugeDataCsv); - CsvColumn firstColumn = table.Column(HugeDataFields.a); - Debug.Log($"[Large CSV] Rows={table.RowCount}, Columns={table.ColumnCount}, Column a={firstColumn.Count}"); - } } } diff --git a/Assets/Scripts/Examples/ScenarioFields.cs b/Assets/Scripts/Examples/ScenarioFields.cs index 1ac67a4..6f6f11a 100644 --- a/Assets/Scripts/Examples/ScenarioFields.cs +++ b/Assets/Scripts/Examples/ScenarioFields.cs @@ -1,10 +1,11 @@ using CSV4Unity.Validation; -namespace CSV4Unity.Fields +namespace CSV4Unity.Examples { /// /// Scenario.csvの列をEnumで参照するためのスキーマです。 /// + [CsvSchema] public enum ScenarioFields { [NotNull] diff --git a/Assets/Scripts/Tests/Manual/CsvCoreManualCheck.cs b/Assets/Scripts/Tests/Manual/CsvCoreManualCheck.cs index 53ee404..787e063 100644 --- a/Assets/Scripts/Tests/Manual/CsvCoreManualCheck.cs +++ b/Assets/Scripts/Tests/Manual/CsvCoreManualCheck.cs @@ -1,5 +1,5 @@ using System; -using CSV4Unity.Fields; +using CSV4Unity.Examples; using CSV4Unity.Validation; using UnityEngine; diff --git a/Assets/Scripts/Tests/Manual/CsvManualCheckFields.cs b/Assets/Scripts/Tests/Manual/CsvManualCheckFields.cs index cb77929..dabf93f 100644 --- a/Assets/Scripts/Tests/Manual/CsvManualCheckFields.cs +++ b/Assets/Scripts/Tests/Manual/CsvManualCheckFields.cs @@ -1,11 +1,12 @@ using System.Text.RegularExpressions; using CSV4Unity.Validation; -namespace CSV4Unity.Fields +namespace CSV4Unity.Tests.Manual { /// /// Rfc4180.csvをEnumで読み込むための手動確認用スキーマです。 /// + [CsvSchema] public enum Rfc4180Fields { Id, @@ -16,6 +17,7 @@ public enum Rfc4180Fields /// /// HeaderMapping.csvのヘッダー補正を確認する手動確認用スキーマです。 /// + [CsvSchema] public enum HeaderMappingFields { [CsvHeader("Item ID")] @@ -31,6 +33,7 @@ public enum HeaderMappingFields /// /// ValidationInvalid.csvの制約検出に使用する手動確認用スキーマです。 /// + [CsvSchema] public enum ManualValidationFields { [PrimaryKey] @@ -48,6 +51,7 @@ public enum ManualValidationFields /// /// ConditionalValidation.csvのCommand別Validationに使用する手動確認用スキーマです。 /// + [CsvSchema] public enum ConditionalValidationFields { Command, diff --git a/Assets/Scripts/Examples/HugeDataFields.cs b/Assets/Scripts/Tests/Manual/HugeDataFields.cs similarity index 57% rename from Assets/Scripts/Examples/HugeDataFields.cs rename to Assets/Scripts/Tests/Manual/HugeDataFields.cs index 681177f..bad45b9 100644 --- a/Assets/Scripts/Examples/HugeDataFields.cs +++ b/Assets/Scripts/Tests/Manual/HugeDataFields.cs @@ -1,8 +1,9 @@ -namespace CSV4Unity.Fields +namespace CSV4Unity.Tests.Manual { /// - /// HugeData.csvの列アクセス確認に使用するスキーマです。 + /// HugeData.csvをRuntime APIで読み込むための手動確認用スキーマです。 /// + /// Inspectorへ登録しないため、CsvSchema属性は付けません。 public enum HugeDataFields { a, diff --git a/Assets/Scripts/Examples/HugeDataFields.cs.meta b/Assets/Scripts/Tests/Manual/HugeDataFields.cs.meta similarity index 100% rename from Assets/Scripts/Examples/HugeDataFields.cs.meta rename to Assets/Scripts/Tests/Manual/HugeDataFields.cs.meta From 851b99445135466cdd2e5236ae1b06b626e8f67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:11:20 +0900 Subject: [PATCH 20/28] =?UTF-8?q?test:=20CsvSchema=E3=81=AE=E5=9E=8B?= =?UTF-8?q?=E7=99=BA=E8=A6=8B=E3=82=92=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditMode/CsvSchemaTypeDiscoveryTests.cs | 77 +++++++++++++++++++ .../CsvSchemaTypeDiscoveryTests.cs.meta | 2 + 2 files changed, 79 insertions(+) create mode 100644 Assets/Scripts/Tests/EditMode/CsvSchemaTypeDiscoveryTests.cs create mode 100644 Assets/Scripts/Tests/EditMode/CsvSchemaTypeDiscoveryTests.cs.meta diff --git a/Assets/Scripts/Tests/EditMode/CsvSchemaTypeDiscoveryTests.cs b/Assets/Scripts/Tests/EditMode/CsvSchemaTypeDiscoveryTests.cs new file mode 100644 index 0000000..2bf5227 --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/CsvSchemaTypeDiscoveryTests.cs @@ -0,0 +1,77 @@ +using System.Linq; +using CSV4Unity.Editor; +using NUnit.Framework; + +namespace CSV4Unity.Tests +{ + public sealed class CsvSchemaTypeDiscoveryTests + { + [CsvSchema] + private enum AttributedSchema + { + Id + } + + private enum UnmarkedSchema + { + Id + } + + [Test] + public void FindAll_AttributedEnumOutsideLegacyNamespace_IsDiscovered() + { + Assert.That(CsvSchemaTypeDiscovery.FindAll().Contains(typeof(AttributedSchema)), Is.True); + } + + [Test] + public void FindAll_UnmarkedEnumOutsideLegacyNamespace_IsNotDiscovered() + { + Assert.That(CsvSchemaTypeDiscovery.FindAll().Contains(typeof(UnmarkedSchema)), Is.False); + } + + [Test] + public void FindAll_ReturnsUniqueTypesInFullNameOrder() + { + var schemas = CsvSchemaTypeDiscovery.FindAll(); + string[] names = schemas + .Select(type => type.FullName ?? type.Name) + .ToArray(); + string[] sortedNames = names + .OrderBy(name => name, System.StringComparer.Ordinal) + .ToArray(); + + Assert.That(schemas.Distinct().Count(), Is.EqualTo(schemas.Count)); + CollectionAssert.AreEqual(sortedNames, names); + } + + [Test] + public void IsLegacySchema_UnmarkedEnumInLegacyNamespace_ReturnsTrue() + { + Assert.That( + CsvSchemaTypeDiscovery.IsLegacySchema(typeof(CSV4Unity.Fields.Tests.LegacySchema)), + Is.True); + } + + [Test] + public void IsLegacySchema_AttributedEnumInLegacyNamespace_ReturnsFalse() + { + Assert.That( + CsvSchemaTypeDiscovery.IsLegacySchema(typeof(CSV4Unity.Fields.Tests.AttributedLegacySchema)), + Is.False); + } + } +} + +namespace CSV4Unity.Fields.Tests +{ + internal enum LegacySchema + { + Id + } + + [CsvSchema] + internal enum AttributedLegacySchema + { + Id + } +} diff --git a/Assets/Scripts/Tests/EditMode/CsvSchemaTypeDiscoveryTests.cs.meta b/Assets/Scripts/Tests/EditMode/CsvSchemaTypeDiscoveryTests.cs.meta new file mode 100644 index 0000000..74cfb6c --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/CsvSchemaTypeDiscoveryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b723ce29d9cd4f838f86f0e30cf28763 From 21c2083c68ed69d2cd3fd7868f79d14370e278d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:11:31 +0900 Subject: [PATCH 21/28] =?UTF-8?q?docs:=20CsvSchema=E3=81=AE=E5=88=A9?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E6=B3=95=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 24 +++++++++++++++++++++++- docs/en/architecture.md | 1 + docs/ja/architecture.md | 9 +++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a52a554..718f114 100644 --- a/README.md +++ b/README.md @@ -266,11 +266,33 @@ Viewerは画面に見える行だけを描画し、検索時もセル文字列 ## Inspector Validation -1. `CSV4Unity.Fields` 名前空間へValidation用Enumを定義します。 +1. Validation用Enumへ `[CsvSchema]` を付けます。 2. UnityのProjectウィンドウでCSVを選択します。 3. Inspectorの `Validation Schema` からEnumを選択します。 4. `Validate CSV` を実行します。 +```csharp +using CSV4Unity; +using CSV4Unity.Validation; + +namespace MyGame.Data +{ + [CsvSchema] + public enum ItemFields + { + [PrimaryKey] + Id, + + [NotNull] + Name + } +} +``` + +`CsvSchema`はUnity EditorがInspector候補を発見するための属性です。`WithFields()`や`CSVLoader.LoadTable()`をコードから使用するだけであれば必須ではありません。 + +旧バージョンとの互換性のため、`CSV4Unity.Fields`名前空間のEnumも当面は候補へ表示されます。新しいスキーマでは名前空間規約を使用せず、`CsvSchema`を付けてください。 + ## Parser設定 ```csharp diff --git a/docs/en/architecture.md b/docs/en/architecture.md index 9f224be..672d982 100644 --- a/docs/en/architecture.md +++ b/docs/en/architecture.md @@ -84,6 +84,7 @@ Responsibility: bind enum fields to document column indices once. - Reflects enum declarations only when a schema is bound. - Validates required headers and rejects ambiguous enum aliases. - Owns only the enum-to-column dictionary, never cell data. +- `CsvSchemaAttribute` registers an enum for discovery by the Unity CSV Inspector. It is not required by runtime enum access APIs. - Can be inspected independently from row access and reused by validators. ### `CsvTable` diff --git a/docs/ja/architecture.md b/docs/ja/architecture.md index 39fadbd..7c61306 100644 --- a/docs/ja/architecture.md +++ b/docs/ja/architecture.md @@ -96,6 +96,7 @@ CsvRow / CsvColumn / CsvCell | 型 | 役割 | |---|---| | `CsvEnumSchema` | Enum値とCSV列番号を対応付ける | +| `CsvSchemaAttribute` | EnumをUnity Inspectorのスキーマ候補として登録する | | `CsvHeaderAttribute` | Enum名と異なるヘッダー名を指定する | | `CsvHeaderPatternAttribute` | 複数表記を正規表現で一意に対応付ける | | `CsvTable` | `CsvDocument` とEnumスキーマを組み合わせる | @@ -215,6 +216,14 @@ Validationは次の2種類へ分けます。 Validation属性は属性1個につき内部規則1個へ変換します。このため、同じ列へCommand別の`TypeConstraint`を複数定義できます。条件は制約を実行するかだけを決め、条件不成立自体をValidationエラーにはしません。 +### Unity Inspectorでのスキーマ発見 + +`CsvSchemaAttribute`をEnumへ付けると、Unity Editorは`TypeCache`を使ってその型を発見し、CSV Inspectorの`Validation Schema`候補へ表示します。Enumを特定の名前空間へ置く必要はありません。 + +この属性はEditor上の発見だけを担当します。Runtimeの`CsvDocument.WithFields()`や`CSVLoader.LoadTable()`は、属性がないEnumも従来どおり利用できます。 + +v0.xでは互換性のため、`CSV4Unity.Fields`名前空間にある属性なしEnumも候補へ含めます。この名前空間規約は新規コードでは使用せず、明示的に`CsvSchemaAttribute`を付けます。 + `PrimaryKey` と `Unique` は条件に一致する行集合を一度だけ走査します。無条件の場合も、各行のセル検証中に列全体を繰り返し走査しません。 ## 依存関係のルール From c8d48cf44f7a9570fffa9296c776bb4a83ad8513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:02:15 +0900 Subject: [PATCH 22/28] =?UTF-8?q?fix:=20TextAsset=20Inspector=E3=81=B8?= =?UTF-8?q?=E3=81=AE=E5=BD=B1=E9=9F=BF=E3=82=92CSV=E3=81=AB=E9=99=90?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CSVLoader/Editor/CsvEditorAssetUtility.cs | 19 ++++++++ .../Editor/CsvEditorAssetUtility.cs.meta | 2 + .../CSVLoader/Editor/CsvInspectorEditor.cs | 45 ++++++++++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs create mode 100644 Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs.meta diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs b/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs new file mode 100644 index 0000000..cd46a8f --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs @@ -0,0 +1,19 @@ +#if UNITY_EDITOR +using System; +using System.IO; + +namespace CSV4Unity.Editor +{ + /// + /// CSV用Editor拡張が対象にするアセットを判定します。 + /// + internal static class CsvEditorAssetUtility + { + internal static bool IsCsvPath(string assetPath) + { + return !string.IsNullOrEmpty(assetPath) && + string.Equals(Path.GetExtension(assetPath), ".csv", StringComparison.OrdinalIgnoreCase); + } + } +} +#endif diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs.meta new file mode 100644 index 0000000..02818b4 --- /dev/null +++ b/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0d42bc19a8148b2b6c574e2b7323ef1 diff --git a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs index 9ca01b0..8e48c83 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs @@ -17,6 +17,7 @@ namespace CSV4Unity.Editor [CustomEditor(typeof(TextAsset))] public sealed class CsvInspectorEditor : UnityEditor.Editor { + private const string BuiltInTextAssetInspectorName = "UnityEditor.TextAssetInspector"; private static readonly CsvSourceEncoding[] SourceEncodingValues = { CsvSourceEncoding.Auto, @@ -54,12 +55,20 @@ public sealed class CsvInspectorEditor : UnityEditor.Editor private CsvSourceEncoding _sourceEncoding; private CsvEncodingInspection _automaticEncodingInspection; private CsvEncodingInspection _encodingInspection; + private UnityEditor.Editor _builtInInspector; private void OnEnable() { + // CustomEditorは拡張子で対象を絞れないため、基礎表示はUnity標準Inspectorへ委譲する。 + Type builtInInspectorType = FindBuiltInTextAssetInspectorType(); + if (builtInInspectorType != null) + { + _builtInInspector = CreateEditor(targets, builtInInspectorType); + } + _csvFile = target as TextAsset; _assetPath = AssetDatabase.GetAssetPath(_csvFile); - _isCsv = string.Equals(Path.GetExtension(_assetPath), ".csv", StringComparison.OrdinalIgnoreCase); + _isCsv = CsvEditorAssetUtility.IsCsvPath(_assetPath); if (!_isCsv) return; RefreshEncodingInspection(); @@ -67,9 +76,29 @@ private void OnEnable() RestoreSelection(_assetPath); } + private void OnDisable() + { + if (_builtInInspector == null) return; + DestroyImmediate(_builtInInspector); + _builtInInspector = null; + } + public override void OnInspectorGUI() { - DrawDefaultInspector(); + if (_isCsv) + { + // CSV本文は専用Viewerで表示するため、標準TextAssetの長いテキストプレビューは描画しない。 + DrawDefaultInspector(); + } + else if (_builtInInspector != null) + { + _builtInInspector.OnInspectorGUI(); + } + else + { + DrawDefaultInspector(); + } + if (!_isCsv || _csvFile == null) return; // TextAssetの標準Inspectorは読み取り専用なので、追加UIだけ操作可能にする。 @@ -85,6 +114,18 @@ public override void OnInspectorGUI() } } + internal static Type FindBuiltInTextAssetInspectorType() + { + Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); + for (int i = 0; i < assemblies.Length; i++) + { + Type type = assemblies[i].GetType(BuiltInTextAssetInspectorName, false); + if (type != null && type != typeof(CsvInspectorEditor)) return type; + } + + return null; + } + private void DrawCsvControls() { EditorGUILayout.Space(10); From 5f2b2f34fb6aa873bd588181117f3ae6af64cdc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:02:24 +0900 Subject: [PATCH 23/28] =?UTF-8?q?feat:=20CSV=20Viewer=E3=81=AB=E3=82=BA?= =?UTF-8?q?=E3=83=BC=E3=83=A0=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CSVLoader/Editor/CsvViewerTable.cs | 115 +++++++++++++----- .../CSVLoader/Editor/CsvViewerWindow.cs | 34 +++++- 2 files changed, 112 insertions(+), 37 deletions(-) diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs index dd11b82..5e5e0d6 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs @@ -12,11 +12,13 @@ namespace CSV4Unity.Editor /// internal sealed class CsvViewerTable { - private const float HeaderHeight = 24f; - private const float RowHeight = 21f; - private const float RowNumberWidth = 56f; + private const float BaseHeaderHeight = 24f; + private const float BaseRowHeight = 21f; + private const float BaseRowNumberWidth = 56f; private const float MinimumColumnWidth = 64f; private const float MaximumInitialColumnWidth = 280f; + private const float MinimumZoom = 0.75f; + private const float MaximumZoom = 2f; private const int CellCacheRowLimit = 256; private readonly CsvDocument _document; @@ -31,6 +33,10 @@ internal sealed class CsvViewerTable private int _resizingColumn = -1; private float _resizeStartMouseX; private float _resizeStartWidth; + private float _zoom = 1f; + private GUIStyle _headerStyle; + private GUIStyle _cellStyle; + private GUIStyle _rowNumberStyle; public CsvViewerTable(CsvDocument document) { @@ -44,6 +50,19 @@ public CsvViewerTable(CsvDocument document) public int FilteredRowCount => string.IsNullOrEmpty(_searchText) ? RowCount : _filteredRows.Count; + internal float Zoom => _zoom; + + public void SetZoom(float zoom) + { + float clamped = Mathf.Clamp(zoom, MinimumZoom, MaximumZoom); + if (Mathf.Approximately(_zoom, clamped)) return; + + _zoom = clamped; + _headerStyle = null; + _cellStyle = null; + _rowNumberStyle = null; + } + public void SetSearch(string searchText) { string normalized = searchText ?? string.Empty; @@ -66,12 +85,14 @@ public void SetSearch(string searchText) public void OnGUI(Rect rect) { - if (rect.width <= 0f || rect.height <= HeaderHeight) return; + float headerHeight = BaseHeaderHeight * _zoom; + float rowHeight = BaseRowHeight * _zoom; + if (rect.width <= 0f || rect.height <= headerHeight) return; - Rect headerRect = new Rect(rect.x, rect.y, rect.width, HeaderHeight); - Rect bodyRect = new Rect(rect.x, rect.y + HeaderHeight, rect.width, rect.height - HeaderHeight); + Rect headerRect = new Rect(rect.x, rect.y, rect.width, headerHeight); + Rect bodyRect = new Rect(rect.x, rect.y + headerHeight, rect.width, rect.height - headerHeight); float contentWidth = CalculateContentWidth(); - float contentHeight = Mathf.Max(bodyRect.height, FilteredRowCount * RowHeight); + float contentHeight = Mathf.Max(bodyRect.height, FilteredRowCount * rowHeight); _scrollPosition = GUI.BeginScrollView( bodyRect, @@ -91,51 +112,59 @@ private void DrawHeader(Rect rect, float contentWidth) EditorGUI.DrawRect(rect, new Color(0.16f, 0.16f, 0.16f, 1f)); GUI.BeginGroup(rect); + float headerHeight = BaseHeaderHeight * _zoom; + float rowNumberWidth = BaseRowNumberWidth * _zoom; float x = -_scrollPosition.x; - DrawHeaderCell(new Rect(x, 0f, RowNumberWidth, HeaderHeight), "#"); - x += RowNumberWidth; + DrawHeaderCell(new Rect(x, 0f, rowNumberWidth, headerHeight), "#"); + x += rowNumberWidth; for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) { - float width = _columnWidths[columnIndex]; + float width = _columnWidths[columnIndex] * _zoom; string name = _document.HasHeader ? _document.Headers[columnIndex] : $"Column {columnIndex + 1}"; - DrawHeaderCell(new Rect(x, 0f, width, HeaderHeight), name); - HandleColumnResize(columnIndex, new Rect(x + width - 3f, 0f, 6f, HeaderHeight)); + DrawHeaderCell(new Rect(x, 0f, width, headerHeight), name); + HandleColumnResize(columnIndex, new Rect(x + width - 3f, 0f, 6f, headerHeight)); x += width; } if (x < contentWidth - _scrollPosition.x) { EditorGUI.DrawRect( - new Rect(x, 0f, contentWidth - _scrollPosition.x - x, HeaderHeight), + new Rect(x, 0f, contentWidth - _scrollPosition.x - x, headerHeight), new Color(0.16f, 0.16f, 0.16f, 1f)); } GUI.EndGroup(); } - private static void DrawHeaderCell(Rect rect, string text) + private void DrawHeaderCell(Rect rect, string text) { GUI.Box(rect, GUIContent.none, EditorStyles.toolbarButton); GUI.Label( - new Rect(rect.x + 6f, rect.y + 2f, Mathf.Max(0f, rect.width - 12f), rect.height - 4f), + new Rect( + rect.x + (6f * _zoom), + rect.y + (2f * _zoom), + Mathf.Max(0f, rect.width - (12f * _zoom)), + rect.height - (4f * _zoom)), new GUIContent(text, text), - EditorStyles.boldLabel); + HeaderStyle); } private void DrawVisibleRows(float viewportHeight, float contentWidth) { - int firstRow = Mathf.Max(0, Mathf.FloorToInt(_scrollPosition.y / RowHeight)); - int visibleCount = Mathf.CeilToInt(viewportHeight / RowHeight) + 2; + float rowHeight = BaseRowHeight * _zoom; + float rowNumberWidth = BaseRowNumberWidth * _zoom; + int firstRow = Mathf.Max(0, Mathf.FloorToInt(_scrollPosition.y / rowHeight)); + int visibleCount = Mathf.CeilToInt(viewportHeight / rowHeight) + 2; int lastRow = Mathf.Min(FilteredRowCount, firstRow + visibleCount); for (int displayRow = firstRow; displayRow < lastRow; displayRow++) { int sourceRow = GetSourceRow(displayRow); - float y = displayRow * RowHeight; - Rect rowRect = new Rect(0f, y, contentWidth, RowHeight); + float y = displayRow * rowHeight; + Rect rowRect = new Rect(0f, y, contentWidth, rowHeight); if ((displayRow & 1) != 0) { @@ -146,21 +175,25 @@ private void DrawVisibleRows(float viewportHeight, float contentWidth) float x = 0f; GUI.Label( - new Rect(x + 5f, y + 1f, RowNumberWidth - 10f, RowHeight - 2f), + new Rect( + x + (5f * _zoom), + y + _zoom, + rowNumberWidth - (10f * _zoom), + rowHeight - (2f * _zoom)), (sourceRow + 1).ToString(), - EditorStyles.miniLabel); - x += RowNumberWidth; + RowNumberStyle); + x += rowNumberWidth; string[] cellTexts = GetRowTexts(sourceRow); for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) { - float width = _columnWidths[columnIndex]; - Rect cellRect = new Rect(x, y, width, RowHeight); + float width = _columnWidths[columnIndex] * _zoom; + Rect cellRect = new Rect(x, y, width, rowHeight); DrawCell(displayRow, columnIndex, cellRect, cellTexts[columnIndex]); x += width; } - EditorGUI.DrawRect(new Rect(0f, y + RowHeight - 1f, contentWidth, 1f), new Color(0f, 0f, 0f, 0.16f)); + EditorGUI.DrawRect(new Rect(0f, y + rowHeight - 1f, contentWidth, 1f), new Color(0f, 0f, 0f, 0.16f)); } } @@ -178,9 +211,13 @@ private void DrawCell(int displayRow, int columnIndex, Rect rect, string text) } GUI.Label( - new Rect(rect.x + 5f, rect.y + 1f, Mathf.Max(0f, rect.width - 10f), rect.height - 2f), + new Rect( + rect.x + (5f * _zoom), + rect.y + _zoom, + Mathf.Max(0f, rect.width - (10f * _zoom)), + rect.height - (2f * _zoom)), new GUIContent(text, text), - EditorStyles.label); + CellStyle); EditorGUI.DrawRect(new Rect(rect.x + rect.width - 1f, rect.y, 1f, rect.height), new Color(0f, 0f, 0f, 0.14f)); Event current = Event.current; @@ -266,7 +303,7 @@ private void HandleColumnResize(int columnIndex, Rect handleRect) } else if (current.type == EventType.MouseDrag && _resizingColumn == columnIndex) { - float delta = current.mousePosition.x - _resizeStartMouseX; + float delta = (current.mousePosition.x - _resizeStartMouseX) / _zoom; _columnWidths[columnIndex] = Mathf.Max(MinimumColumnWidth, _resizeStartWidth + delta); current.Use(); } @@ -319,9 +356,25 @@ private static string ToSingleLine(string value) private float CalculateContentWidth() { - float width = RowNumberWidth; + float width = BaseRowNumberWidth; for (int i = 0; i < _columnWidths.Length; i++) width += _columnWidths[i]; - return width; + return width * _zoom; + } + + private GUIStyle HeaderStyle => _headerStyle ??= CreateScaledStyle(EditorStyles.boldLabel, 12); + + private GUIStyle CellStyle => _cellStyle ??= CreateScaledStyle(EditorStyles.label, 12); + + private GUIStyle RowNumberStyle => _rowNumberStyle ??= CreateScaledStyle(EditorStyles.miniLabel, 10); + + private GUIStyle CreateScaledStyle(GUIStyle source, int baseFontSize) + { + if (Mathf.Approximately(_zoom, 1f)) return source; + + return new GUIStyle(source) + { + fontSize = Mathf.Max(1, Mathf.RoundToInt(baseFontSize * _zoom)) + }; } private static float[] CreateInitialColumnWidths(CsvDocument document) diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs index 9b44ef2..2087a5a 100644 --- a/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs +++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs @@ -1,6 +1,5 @@ #if UNITY_EDITOR using System; -using System.IO; using UnityEditor; using UnityEngine; @@ -13,10 +12,13 @@ public sealed class CsvViewerWindow : EditorWindow { private const float ToolbarHeight = 22f; private const float StatusHeight = 20f; + private const float MinimumZoom = 0.75f; + private const float MaximumZoom = 2f; [SerializeField] private TextAsset _csvAsset; [SerializeField] private bool _hasHeader = true; [SerializeField] private string _searchText = string.Empty; + [SerializeField] private float _zoom = 1f; [NonSerialized] private CsvViewerTable _table; [NonSerialized] private string _errorMessage; @@ -28,7 +30,7 @@ public static void Open(TextAsset csvAsset) { CsvViewerWindow window = GetWindow(); window.titleContent = new GUIContent("CSV Viewer"); - window.minSize = new Vector2(480f, 260f); + window.minSize = new Vector2(640f, 260f); window.SetAsset(csvAsset); window.Show(); } @@ -38,7 +40,7 @@ private static void OpenWindow() { CsvViewerWindow window = GetWindow(); window.titleContent = new GUIContent("CSV Viewer"); - window.minSize = new Vector2(480f, 260f); + window.minSize = new Vector2(640f, 260f); window.Show(); } @@ -53,13 +55,15 @@ private static bool CanOpenSelectedAsset() { if (!(Selection.activeObject is TextAsset textAsset)) return false; string path = AssetDatabase.GetAssetPath(textAsset); - return string.Equals(Path.GetExtension(path), ".csv", StringComparison.OrdinalIgnoreCase); + return CsvEditorAssetUtility.IsCsvPath(path); } private void OnEnable() { titleContent = new GUIContent("CSV Viewer"); - minSize = new Vector2(480f, 260f); + minSize = new Vector2(640f, 260f); + if (_zoom <= 0f) _zoom = 1f; + _zoom = Mathf.Clamp(_zoom, MinimumZoom, MaximumZoom); EditorApplication.projectChanged += HandleProjectChanged; Reload(); } @@ -124,6 +128,23 @@ private void DrawToolbar(Rect rect) if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(54f))) Reload(); + GUILayout.Space(6f); + GUILayout.Label("Zoom", EditorStyles.miniLabel, GUILayout.Width(34f)); + EditorGUI.BeginChangeCheck(); + float zoom = GUILayout.HorizontalSlider( + _zoom, + MinimumZoom, + MaximumZoom, + GUILayout.Width(82f)); + if (EditorGUI.EndChangeCheck()) + { + _zoom = Mathf.Round(zoom * 20f) / 20f; + _table?.SetZoom(_zoom); + Repaint(); + } + + GUILayout.Label($"{_zoom * 100f:0}%", EditorStyles.miniLabel, GUILayout.Width(34f)); + GUILayout.FlexibleSpace(); EditorGUI.BeginChangeCheck(); string search = EditorGUILayout.TextField( @@ -193,7 +214,7 @@ private void Reload() } string assetPath = AssetDatabase.GetAssetPath(_csvAsset); - if (!string.Equals(Path.GetExtension(assetPath), ".csv", StringComparison.OrdinalIgnoreCase)) + if (!CsvEditorAssetUtility.IsCsvPath(assetPath)) { _errorMessage = "The selected TextAsset is not a .csv file."; Repaint(); @@ -211,6 +232,7 @@ private void Reload() CsvDocument document = CSVLoader.LoadDocument(_csvAsset, options); _table = new CsvViewerTable(document); + _table.SetZoom(_zoom); _table.SetSearch(_searchText); _assetHash = AssetDatabase.GetAssetDependencyHash(assetPath); } From ce759d18880a4433dc586cffaa4ff12cc63ad9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:02:33 +0900 Subject: [PATCH 24/28] =?UTF-8?q?test:=20Editor=20UX=E3=81=AE=E5=AF=BE?= =?UTF-8?q?=E8=B1=A1=E5=88=A4=E5=AE=9A=E3=81=A8=E3=82=BA=E3=83=BC=E3=83=A0?= =?UTF-8?q?=E3=82=92=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditMode/CsvEditorAssetUtilityTests.cs | 25 +++++++++++++++++++ .../CsvEditorAssetUtilityTests.cs.meta | 2 ++ .../Tests/EditMode/CsvViewerTableTests.cs | 13 ++++++++++ Assets/TestData/CSV4Unity/NonCsvTextAsset.txt | 3 +++ .../CSV4Unity/NonCsvTextAsset.txt.meta | 7 ++++++ 5 files changed, 50 insertions(+) create mode 100644 Assets/Scripts/Tests/EditMode/CsvEditorAssetUtilityTests.cs create mode 100644 Assets/Scripts/Tests/EditMode/CsvEditorAssetUtilityTests.cs.meta create mode 100644 Assets/TestData/CSV4Unity/NonCsvTextAsset.txt create mode 100644 Assets/TestData/CSV4Unity/NonCsvTextAsset.txt.meta diff --git a/Assets/Scripts/Tests/EditMode/CsvEditorAssetUtilityTests.cs b/Assets/Scripts/Tests/EditMode/CsvEditorAssetUtilityTests.cs new file mode 100644 index 0000000..0a0de4d --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/CsvEditorAssetUtilityTests.cs @@ -0,0 +1,25 @@ +using CSV4Unity.Editor; +using NUnit.Framework; + +namespace CSV4Unity.Tests.EditMode +{ + public sealed class CsvEditorAssetUtilityTests + { + [TestCase("Assets/Data/Scenario.csv", true)] + [TestCase("Assets/Data/Scenario.CSV", true)] + [TestCase("Assets/Data/Notes.txt", false)] + [TestCase("Assets/Data/Scenario.csv.meta", false)] + [TestCase("", false)] + [TestCase(null, false)] + public void IsCsvPath_DetectsOnlyCsvExtension(string path, bool expected) + { + Assert.That(CsvEditorAssetUtility.IsCsvPath(path), Is.EqualTo(expected)); + } + + [Test] + public void FindBuiltInTextAssetInspectorType_UnityEditorTypeIsAvailable() + { + Assert.That(CsvInspectorEditor.FindBuiltInTextAssetInspectorType(), Is.Not.Null); + } + } +} diff --git a/Assets/Scripts/Tests/EditMode/CsvEditorAssetUtilityTests.cs.meta b/Assets/Scripts/Tests/EditMode/CsvEditorAssetUtilityTests.cs.meta new file mode 100644 index 0000000..4dad8ce --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/CsvEditorAssetUtilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a640fac2ed154a18b4b057bf344e6411 diff --git a/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs b/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs index cc45030..b669830 100644 --- a/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs +++ b/Assets/Scripts/Tests/EditMode/CsvViewerTableTests.cs @@ -48,5 +48,18 @@ public void SetSearch_EmptyTextRestoresAllRows() Assert.That(table.FilteredRowCount, Is.EqualTo(3)); } + + [TestCase(0.1f, 0.75f)] + [TestCase(1.25f, 1.25f)] + [TestCase(3f, 2f)] + public void SetZoom_ClampsSupportedRange(float zoom, float expected) + { + CsvDocument document = CSVLoader.LoadDocument(Csv); + var table = new CsvViewerTable(document); + + table.SetZoom(zoom); + + Assert.That(table.Zoom, Is.EqualTo(expected)); + } } } diff --git a/Assets/TestData/CSV4Unity/NonCsvTextAsset.txt b/Assets/TestData/CSV4Unity/NonCsvTextAsset.txt new file mode 100644 index 0000000..5f823bb --- /dev/null +++ b/Assets/TestData/CSV4Unity/NonCsvTextAsset.txt @@ -0,0 +1,3 @@ +CSV4Unity non-CSV Inspector check. + +This file must use Unity's standard TextAsset Inspector without CSV controls. diff --git a/Assets/TestData/CSV4Unity/NonCsvTextAsset.txt.meta b/Assets/TestData/CSV4Unity/NonCsvTextAsset.txt.meta new file mode 100644 index 0000000..5e4919a --- /dev/null +++ b/Assets/TestData/CSV4Unity/NonCsvTextAsset.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 094949e8f8e7430fb77e0b30dcbef2de +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From ac193f214841cfe969cdfd2864e3ce57ad4bfe7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:02:44 +0900 Subject: [PATCH 25/28] =?UTF-8?q?docs:=20Inspector=E3=81=A8Viewer=E3=82=BA?= =?UTF-8?q?=E3=83=BC=E3=83=A0=E3=81=AE=E4=BB=95=E6=A7=98=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +++ docs/en/architecture.md | 4 ++-- docs/ja/architecture.md | 8 +++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 718f114..4c8e56b 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ CSV4Unityはインポート時にファイルを自動変換しません。変 ProjectウィンドウでCSVを選択し、Inspectorの `Open CSV Viewer` を押すと、CSVを読み取り専用の表として確認できます。CSVを右クリックして `Open in CSV Viewer` を選ぶか、`Window > CSV4Unity > CSV Viewer` から開くこともできます。 - `Header` で先頭行をヘッダーとして扱うか切り替え +- `Zoom` で表を75%から200%まで拡大・縮小 - 検索欄で全セルを大文字小文字を区別せず絞り込み - ヘッダー境界のドラッグで列幅を変更 - セルの右クリックでセルまたは行をコピー @@ -264,6 +265,8 @@ ProjectウィンドウでCSVを選択し、Inspectorの `Open CSV Viewer` を押 Viewerは画面に見える行だけを描画し、検索時もセル文字列の全コピーを作りません。CSVの編集や保存は行わず、表示にはRuntimeと同じParserを使用します。 +CSV用の追加UIは`.csv`のTextAssetにだけ表示されます。CSV本文のテキストプレビューはInspectorへ重複表示せず、表形式のViewerから確認します。`.txt`や`.json`など、それ以外のTextAssetはUnity標準Inspectorで表示します。 + ## Inspector Validation 1. Validation用Enumへ `[CsvSchema]` を付けます。 diff --git a/docs/en/architecture.md b/docs/en/architecture.md index 672d982..ae44668 100644 --- a/docs/en/architecture.md +++ b/docs/en/architecture.md @@ -124,10 +124,10 @@ Responsibility: adapt Unity inputs to the pure C# core. ### Unity Editor tools -- `CsvInspectorEditor` provides source-encoding inspection, explicit UTF-8 conversion, and the viewer and validation entry points. +- `CsvInspectorEditor` delegates non-CSV assets to Unity's built-in TextAsset Inspector. For CSV assets it omits the redundant raw-text preview and adds encoding, viewer, and validation controls. - `CsvEncodingUtility` validates source bytes and converts a selected source encoding to UTF-8. - `CsvViewerWindow` owns asset selection, parsing, search state, and reload behavior. -- `CsvViewerTable` draws only visible rows and provides column resizing and copy commands. +- `CsvViewerTable` draws only visible rows and provides 75-200% zoom, column resizing, and copy commands. The viewer treats `CsvDocument` as read-only data. It caches display strings for at most 256 rows instead of duplicating the complete CSV as a two-dimensional string array. Editing and writing remain separate future responsibilities. diff --git a/docs/ja/architecture.md b/docs/ja/architecture.md index 7c61306..727cf09 100644 --- a/docs/ja/architecture.md +++ b/docs/ja/architecture.md @@ -152,12 +152,14 @@ public enum ItemField | 型 | 役割 | |---|---| -| `CsvInspectorEditor` | CSVの文字コード確認・UTF-8変換と、Viewer・Validationの入口を提供する | +| `CsvInspectorEditor` | Unity標準TextAsset InspectorへCSV専用の文字コード変換・Viewer・Validationを追加する | | `CsvEncodingUtility` | 元バイト列の文字コードを検査し、明示された文字コードからUTF-8へ変換する | | `CsvViewerWindow` | CSVアセットの選択、解析、検索条件、再読込を管理する | -| `CsvViewerTable` | 表示範囲の行だけを描画し、列幅変更とコピー操作を提供する | +| `CsvViewerTable` | 表示範囲の行だけを描画し、75〜200%の拡大率、列幅変更、コピー操作を提供する | -ViewerはEditor専用であり、`CsvDocument`を読み取り専用データとして利用します。表示用文字列は最大256行分だけキャッシュし、CSV全体を表示専用の二次元文字列配列へ複製しません。編集や書き出しは別の責務とします。 +ViewerはEditor専用であり、`CsvDocument`を読み取り専用データとして利用します。表示用文字列は最大256行分だけキャッシュし、CSV全体を表示専用の二次元文字列配列へ複製しません。編集や書き出しは別の責務とします。拡大率は描画時の行高・列幅・文字サイズへ適用し、CSVデータやキャッシュ内容は複製しません。 + +Unityの`CustomEditor`はファイル拡張子で対象を限定できないため、`CsvInspectorEditor`はCSV以外ではUnity標準のTextAsset Inspectorへ表示を委譲します。CSVでは本文のテキストプレビューを省略し、表形式のViewerを入口にします。CSV用UIは拡張子が`.csv`のアセットにだけ追加します。 文字コード検査はUnityが生成した`TextAsset.text`ではなく、プロジェクト内の元CSVファイルをバイト列として読み取ります。BOMを優先し、BOMなしは厳密なUTF-8、次にShift_JIS(CP932)として検査します。誤判定時はInspectorで変換元を指定できます。ファイルの自動書き換えは行わず、確認ダイアログを伴う手動操作だけでUTF-8(BOMなし)へ変換します。 From 59688e176db402c318f636c51a3940ef924fd3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:38:02 +0900 Subject: [PATCH 26/28] =?UTF-8?q?chore:=20v0.3.0=E3=81=AE=E3=83=91?= =?UTF-8?q?=E3=83=83=E3=82=B1=E3=83=BC=E3=82=B8=E6=83=85=E5=A0=B1=E3=82=92?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Plugins/CSVLoader/package.json | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/Assets/Plugins/CSVLoader/package.json b/Assets/Plugins/CSVLoader/package.json index 88bc25b..0bb6a62 100644 --- a/Assets/Plugins/CSVLoader/package.json +++ b/Assets/Plugins/CSVLoader/package.json @@ -1,18 +1,23 @@ { "name": "com.cotore.csv4unity", "displayName": "CSV4Unity", - "author": { - "name": "cotore", - "url": "https://github.com/cotore-game" + "author": { + "name": "cotore", + "url": "https://github.com/cotore-game" }, - "version": "0.2.1", + "version": "0.3.0", "unity": "6000.0", - "description": "An RFC 4180 CSV reader for Unity with enum column access and attribute validation.", - "keywords": [ - "csv", - "data", - "validation", - "utility" + "description": "An RFC 4180 CSV reader for Unity with enum column access, attribute validation, encoding tools, and a read-only viewer.", + "documentationUrl": "https://cotore-game.github.io/CSV4Unity/", + "changelogUrl": "https://github.com/cotore-game/CSV4Unity/releases", + "licensesUrl": "https://github.com/cotore-game/CSV4Unity/blob/main/LICENSE", + "keywords": [ + "csv", + "rfc4180", + "data", + "validation", + "editor", + "utility" ], "license": "MIT", "category": "Utility", From 3edd02570448b8c4bf274850d46ace564be1cab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:38:11 +0900 Subject: [PATCH 27/28] =?UTF-8?q?docs:=20v0.3.0=E5=90=91=E3=81=91=E3=83=89?= =?UTF-8?q?=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- README_EN.md | 49 ++++++++++++++++++++++++++++++++++++++++++++----- docs/docfx.json | 2 +- docs/index.md | 2 +- docs/toc.yml | 2 +- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4c8e56b..1665200 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ CSVをクラスへ一括変換せず、セルを必要なときに指定した Unity Package Managerの `Add package from git URL...` に次のURLを指定します。 ```text -https://github.com/cotore-game/CSV4Unity.git?path=/Assets/Plugins/CSVLoader#v0.2.0 +https://github.com/cotore-game/CSV4Unity.git?path=/Assets/Plugins/CSVLoader#v0.3.0 ``` 安定した利用には、リリースタグまたはコミットを固定したURLを使用してください。 diff --git a/README_EN.md b/README_EN.md index 65823a3..47359f9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -17,20 +17,22 @@ CSV4Unity reads CSV text into row, column, and cell views for Unity. Values rema - RFC 4180 quoted fields, escaped quotes, commas, and embedded line breaks - Enum-based column access +- Exact, case-insensitive, alias, and regular-expression header mapping - Header-name and column-index access - Row and column views over one document - Explicit cell conversion - Explicitly created search indices -- Attribute-based validation -- Unity Inspector validation -- Read-only CSV Viewer +- Attribute-based and conditional row validation +- Unity Inspector validation with explicit `[CsvSchema]` discovery +- Source-encoding inspection and manual UTF-8 conversion +- Read-only CSV Viewer with search, copying, column resizing, and zoom ## Installation Add this URL through Unity Package Manager: ```text -https://github.com/cotore-game/CSV4Unity.git?path=/Assets/Plugins/CSVLoader#v0.2.0 +https://github.com/cotore-game/CSV4Unity.git?path=/Assets/Plugins/CSVLoader#v0.3.0 ``` ## Basic usage @@ -55,6 +57,19 @@ CsvDocument document = CSVLoader.LoadDocument(csvAsset); string name = document.Row(0)["Name"].GetString(); ``` +Use `CsvHeader` when an Enum field and CSV header have different names. Use `CsvHeaderPattern` only when one field must accept multiple spellings. + +```csharp +public enum ItemField +{ + [CsvHeader("Item ID")] + Id, + + [CsvHeader("DISPLAY NAME", IgnoreCase = true)] + DisplayName +} +``` + ## Validation ```csharp @@ -91,11 +106,35 @@ public enum ScenarioField The default group is zero. Supported comparisons are `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `IsEmpty`, `IsNotEmpty`, `In`, and `NotIn`. Groups are declarative and have no `if / else` execution order. +## CSV Encoding + +Runtime loading expects UTF-8 CSV data. The CSV Inspector detects UTF-8, Shift_JIS, UTF-16, and UTF-32 source files and provides a confirmed manual conversion to UTF-8 without a BOM. Conversion stores the original bytes in the project's `Library` folder so the previous file can be restored. Importing an asset never rewrites it automatically. + +## Inspector Validation + +Add `[CsvSchema]` to an Enum to make it available in the CSV Inspector without requiring a specific namespace. + +```csharp +[CsvSchema] +public enum ItemField +{ + [PrimaryKey] + Id, + + [NotNull] + Name +} +``` + +`CsvSchema` is only used for Editor discovery. Runtime APIs such as `WithFields()` and `CSVLoader.LoadTable()` do not require it. + ## CSV Viewer Select a CSV asset and click `Open CSV Viewer` in the Inspector. The viewer is also available from `Assets > Open in CSV Viewer` and `Window > CSV4Unity > CSV Viewer`. -The read-only table supports headerless files, case-insensitive search, resizable columns, cell or row copying, and automatic reload after asset changes. It virtualizes row drawing and uses the same parser as the Runtime API. +The read-only table supports headerless files, case-insensitive search, 75-200% zoom, resizable columns, cell or row copying, and automatic reload after asset changes. It virtualizes row drawing and uses the same parser as the Runtime API. + +CSV-only controls appear only for `.csv` TextAssets. Raw CSV text is not duplicated in the Inspector because the table viewer provides the readable representation. Other TextAssets continue to use Unity's built-in Inspector. The Japanese README is the canonical user documentation while the API is being stabilized. See [the architecture document](./docs/en/architecture.md) for the current class boundaries. diff --git a/docs/docfx.json b/docs/docfx.json index 67eff93..c51fd5c 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -16,7 +16,7 @@ ], "build": { "content": [ - { "files": ["api/**.yml", "api/index.md"] }, + { "files": ["api/**.yml"] }, { "files": ["index.md", "toc.yml", "ja/**.md", "en/**.md"] } ], "output": "_site", diff --git a/docs/index.md b/docs/index.md index 336170f..1f3c5a0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ CSV4Unityは、RFC 4180形式のCSVをUnityで扱うためのライブラリです。 -- [APIリファレンス](api/index.md) +- [APIリファレンス](api/CSV4Unity.html) - [コア設計(日本語)](ja/architecture.md) - [Core architecture (English)](en/architecture.md) - [GitHubリポジトリ](https://github.com/cotore-game/CSV4Unity) diff --git a/docs/toc.yml b/docs/toc.yml index 0f2e8e2..6086af7 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,7 +1,7 @@ - name: Home href: index.md - name: APIリファレンス - href: api/ + href: api/CSV4Unity.html - name: コア設計(日本語) href: ja/architecture.md - name: ベンチマーク(日本語) From 20524101bd1593974e941380a3f93a26f872d4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B3=E3=83=88=E3=83=AC?= <102813037+cotore-game@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:51:52 +0900 Subject: [PATCH 28/28] =?UTF-8?q?ci:=20main=E3=83=9E=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E5=89=8D=E3=81=AE=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docs.yml | 13 ++++++++++--- .github/workflows/unity-tests.yml | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f1485ce..4740dd1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,6 +1,12 @@ name: Docs on: + pull_request: + branches: [main] + paths: + - "Assets/Plugins/CSVLoader/Runtime/**/*.cs" + - "docs/**" + - ".github/workflows/docs.yml" push: branches: [main, develop] paths: @@ -11,8 +17,6 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: docs-${{ github.ref }} @@ -54,8 +58,11 @@ jobs: deploy: name: Deploy to GitHub Pages needs: build - if: github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 067ca97..c704401 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -1,6 +1,13 @@ name: Unity Tests on: + pull_request: + branches: [main] + paths: + - "Assets/**" + - "Packages/**" + - "ProjectSettings/**" + - ".github/workflows/unity-tests.yml" push: branches: [main] paths: @@ -33,11 +40,23 @@ jobs: restore-keys: | Library-${{ runner.os }}- + - name: Verify Unity license configuration + shell: bash + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + if [[ -z "$UNITY_LICENSE" && -z "$UNITY_SERIAL" ]]; then + echo "::error::Configure either the UNITY_LICENSE or UNITY_SERIAL repository secret before running Unity tests." + exit 1 + fi + - name: Run EditMode tests id: tests uses: game-ci/unity-test-runner@v4 env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} with: