Direct Lake 模型中的实体名称更改会被还原
在 Tabular Editor 3 中编辑 Direct Lake 表分区的 EntityName 后,模型在 Power BI 中重新加载时可能会恢复为原始名称。这种情况往往看起来像是 TE3 没有保存更改,但其实这是因为 Power BI 在刷新期间解释 Direct Lake 元数据的方式。
症状
- 表的元数据更改在 TE3 中可见,但在 Power BI 中刷新模型后会被还原。
- 被还原的是 Direct Lake 表,这些表的元数据是在 Power BI 之外修改的。
- 刷新操作不会报出明确错误,但已重命名的对象会恢复为原始名称。
根本原因
Power BI 通过 SourceLineageTag 属性将 Direct Lake 表与其来源绑定。当该标记与当前分区的 EntityName 不匹配时,Power BI 会认为该表应与原始数据源保持同步,并恢复先前的元数据。 Direct Lake 分区还要求通过 ChangedProperties 集合记录有意做出的更改;否则,Power BI 会忽略在服务外所做的手动编辑。
解决步骤
- 打开表分区。 对于每个 Direct Lake 表,编辑关联的
EntityName。 - 同步分区详细信息。
- 将表的
SourceLineageTag设置为与新的EntityName完全一致。 - 在表的
ChangedProperties集合中将Name属性设置为 true,以便 Power BI 将重命名视为有意更改。
- 将表的
- 在 TE3 中保存模型。
- 在 Power BI 中刷新受影响的表(或整个模型)。 这些名称现在应能保持不变。
重要说明
- 重命名表时,TE3 不会自动更新
SourceLineageTag。务必手动确保该标记保持一致。 - 只有 Direct Lake 表(以及其他复合表)才需要
ChangedProperties标志;传统的导入模式模型不需要它。 - 这些行为源自 Power BI 的元数据同步规则,而不是 TE3 的存储机制。
使用 C# 自动化批量更新
需要调整多个 Direct Lake 表时,可以运行以下 TE3 脚本。它会提示输入新的实体名称,更新每个选定的表,同步 SourceLineageTag,并标记已更改的元数据。
在 TE3 中使用: 选择相关的 Direct Lake 表,打开 C# Script 窗口,粘贴脚本并运行。
// -------- Namespaces --------
using System;
using System.Linq;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows.Forms;
using TW = TabularEditor.TOMWrapper;
// -------- Guard: need tables selected --------
var tables = Selected.Tables.ToList();
if (tables.Count == 0)
{
Warning("Select one or more tables first.");
return;
}
// -------- Build editable rows from selected tables --------
var candidates = tables
.Select(table => new { Table = table, Partition = table.Partitions.OfType<TW.EntityPartition>().FirstOrDefault() })
.ToList();
foreach (var skipped in candidates.Where(c => c.Partition == null))
{
Warning($"Skipping '{skipped.Table.Name}': no Entity partition.");
}
var rows = new BindingList<EntityEditRow>(
candidates
.Where(c => c.Partition != null)
.Select(c => new EntityEditRow(c.Table, c.Partition))
.ToList());
if (rows.Count == 0)
{
Warning("No selected tables have an Entity partition. Nothing to edit.");
return;
}
// -------- Show batch dialog --------
using (var dialog = new BatchEntityEditor(rows))
{
if (dialog.ShowDialog() != DialogResult.OK)
{
Info("Cancelled. No changes applied.");
return;
}
}
// -------- Apply changes --------
const string ExtendedPropertyName = "Changed Property Name";
var updated = 0;
foreach (var row in rows)
{
try
{
if (!row.ApplyChanges(ExtendedPropertyName, Warning))
continue;
updated++;
Output($"Updated '{row.TableName}': Entity='{row.CurrentEntity}', Partition='{row.Partition.Name}', SourceLineageTag='{row.CurrentEntity}'.");
}
catch (Exception ex)
{
Error($"Failed on '{row.TableName}': {ex.Message}");
}
}
Info($"Done. {updated} table(s) updated.");
// ====================== Support types / UI ======================
public class EntityEditRow
{
public EntityEditRow(TW.Table table, TW.EntityPartition partition)
{
Table = table ?? throw new ArgumentNullException(nameof(table));
Partition = partition ?? throw new ArgumentNullException(nameof(partition));
CurrentEntity = partition.EntityName ?? string.Empty;
NewEntity = CurrentEntity;
}
[Browsable(false)]
public TW.Table Table { get; }
[Browsable(false)]
public TW.EntityPartition Partition { get; }
public string TableName => Table.Name;
public string CurrentEntity { get; private set; }
public string NewEntity { get; set; }
public bool ApplyChanges(string extendedPropertyName, Action<string> warn)
{
var target = NewEntity ?? string.Empty;
if (string.IsNullOrWhiteSpace(target) ||
string.Equals(target, CurrentEntity, StringComparison.Ordinal))
{
return false;
}
Partition.EntityName = target;
if (!string.Equals(Partition.Name, target, StringComparison.Ordinal))
{
var nameConflict = Table.Partitions
.Where(p => !ReferenceEquals(p, Partition))
.Any(p => string.Equals(p.Name, target, StringComparison.Ordinal));
if (nameConflict)
{
warn?.Invoke($"Partition rename skipped for '{TableName}': another partition already named '{target}'.");
}
else
{
try
{
Partition.Name = target;
}
catch (Exception ex)
{
warn?.Invoke($"Partition rename failed for '{TableName}': {ex.Message}");
}
}
}
try
{
Table.SourceLineageTag = target;
}
catch (Exception ex)
{
warn?.Invoke($"SourceLineageTag not set on '{TableName}': {ex.Message}");
}
Table.SetExtendedProperty(extendedPropertyName, "true", TW.ExtendedPropertyType.String);
CurrentEntity = target;
return true;
}
}
public class BatchEntityEditor : Form
{
private readonly BindingList<EntityEditRow> rows;
private DataGridView grid;
public BatchEntityEditor(BindingList<EntityEditRow> rows)
{
this.rows = rows ?? throw new ArgumentNullException(nameof(rows));
BuildUi();
}
private void BuildUi()
{
Text = "Edit Entity names for selected tables";
TopMost = true;
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterScreen;
AutoScaleMode = AutoScaleMode.Dpi;
Font = new System.Drawing.Font("Segoe UI", 9F);
Width = 900;
Height = 600;
MinimumSize = new System.Drawing.Size(820, 500);
FormBorderStyle = FormBorderStyle.Sizable;
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 3,
Padding = new Padding(10)
};
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 56f));
Controls.Add(root);
root.Controls.Add(new Label
{
Text = "Edit the Entity name for each table. Leave 'New Entity' unchanged to skip.",
AutoSize = true,
Dock = DockStyle.Fill,
Padding = new Padding(0, 0, 0, 6)
}, 0, 0);
grid = new DataGridView
{
Dock = DockStyle.Fill,
AutoGenerateColumns = false,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
ReadOnly = false,
RowHeadersVisible = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill
};
grid.Columns.AddRange(
new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(EntityEditRow.TableName),
HeaderText = "Table",
ReadOnly = true,
FillWeight = 28
},
new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(EntityEditRow.CurrentEntity),
HeaderText = "Current Entity",
ReadOnly = true,
FillWeight = 36
},
new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(EntityEditRow.NewEntity),
HeaderText = "New Entity",
FillWeight = 36
});
grid.DataSource = rows;
root.Controls.Add(grid, 0, 1);
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.RightToLeft,
WrapContents = false,
Padding = new Padding(0)
};
var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, AutoSize = true, Height = 32, Width = 110, Margin = new Padding(8, 8, 0, 8) };
var cancel = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, AutoSize = true, Height = 32, Width = 110, Margin = new Padding(8, 8, 8, 8) };
buttons.Controls.Add(ok);
buttons.Controls.Add(cancel);
AcceptButton = ok;
CancelButton = cancel;
root.Controls.Add(buttons, 0, 2);
Shown += (_, _) =>
{
grid.ClearSelection();
if (grid.Rows.Count > 0)
{
grid.CurrentCell = grid.Rows[0].Cells[2];
grid.BeginEdit(true);
}
};
}
}
Note
该脚本使用 LLM 进行 Code Assist 生成,但已由 Tabular Editor 团队测试。
运行该脚本时,只会更新获得新实体名称的表。脚本运行结束后,检查更改、保存模型,并在 Power BI 中刷新,以确认元数据能够持久保留。
最后,在从 Power BI 刷新之前,打开每个已更新的分区,并验证 ChangedProperties 集合中包含 Name。