From 82ce09b0ca21aa9bb0f1bcd0519378c4cd4c3e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20L=C3=B8nskov?= Date: Thu, 9 Apr 2026 15:35:34 +0200 Subject: [PATCH 1/5] Added How tos around c# scripting. --- content/features/Useful-script-snippets.md | 3 + .../scripting-add-clone-remove-objects.md | 172 ++++++++ .../how-tos/scripting-check-object-types.md | 142 +++++++ .../scripting-dynamic-linq-vs-csharp.md | 197 +++++++++ .../how-tos/scripting-filter-query-linq.md | 165 +++++++ .../scripting-navigate-tom-hierarchy.md | 150 +++++++ .../scripting-perspectives-translations.md | 131 ++++++ content/how-tos/scripting-ui-helpers.md | 401 ++++++++++++++++++ .../how-tos/scripting-use-selected-object.md | 155 +++++++ .../scripting-work-with-annotations.md | 131 ++++++ .../scripting-work-with-dependencies.md | 156 +++++++ .../scripting-work-with-expressions.md | 174 ++++++++ content/how-tos/toc.md | 13 + 13 files changed, 1990 insertions(+) create mode 100644 content/how-tos/scripting-add-clone-remove-objects.md create mode 100644 content/how-tos/scripting-check-object-types.md create mode 100644 content/how-tos/scripting-dynamic-linq-vs-csharp.md create mode 100644 content/how-tos/scripting-filter-query-linq.md create mode 100644 content/how-tos/scripting-navigate-tom-hierarchy.md create mode 100644 content/how-tos/scripting-perspectives-translations.md create mode 100644 content/how-tos/scripting-ui-helpers.md create mode 100644 content/how-tos/scripting-use-selected-object.md create mode 100644 content/how-tos/scripting-work-with-annotations.md create mode 100644 content/how-tos/scripting-work-with-dependencies.md create mode 100644 content/how-tos/scripting-work-with-expressions.md diff --git a/content/features/Useful-script-snippets.md b/content/features/Useful-script-snippets.md index 36634001..944b4db0 100644 --- a/content/features/Useful-script-snippets.md +++ b/content/features/Useful-script-snippets.md @@ -21,6 +21,9 @@ Here's a collection of small script snippets to get you started using the [Advan Also, make sure to check out our script library @csharp-script-library, for some more real-life examples of what you can do with the scripting capabilities of Tabular Editor. +> [!TIP] +> For structured, pattern-by-pattern reference material on C# scripting and Dynamic LINQ, see the [Scripting Patterns](/how-tos/scripting-navigate-tom-hierarchy) how-to series. It covers object navigation, type checking, LINQ querying, dependencies, annotations, expressions and more. + *** ## Create measures from columns diff --git a/content/how-tos/scripting-add-clone-remove-objects.md b/content/how-tos/scripting-add-clone-remove-objects.md new file mode 100644 index 00000000..32f17898 --- /dev/null +++ b/content/how-tos/scripting-add-clone-remove-objects.md @@ -0,0 +1,172 @@ +--- +uid: how-to-add-clone-remove-objects +title: How to Add, Clone and Remove Objects +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Add, Clone and Remove Objects + +C# scripts can create new model objects, clone existing ones and delete objects. This article covers the Add, Clone and Delete patterns. + +## Quick reference + +```csharp +// Add objects +table.AddMeasure("Name", "DAX Expression", "Display Folder"); +table.AddCalculatedColumn("Name", "DAX Expression", "Display Folder"); +table.AddDataColumn("Name", "SourceColumn", "Display Folder", DataType.String); +table.AddHierarchy("Name", "Display Folder", col1, col2, col3); +Model.AddCalculatedTable("Name", "DAX Expression"); +Model.AddPerspective("Name"); +Model.AddRole("Name"); +Model.AddTranslation("da-DK"); + +// Relationships +var rel = Model.AddRelationship(); +rel.FromColumn = Model.Tables["Sales"].Columns["ProductKey"]; +rel.ToColumn = Model.Tables["Product"].Columns["ProductKey"]; + +// Clone +var clone = measure.Clone("New Name"); // same table +var clone = measure.Clone("New Name", true, targetTable); // different table + +// Delete (always materialize first when deleting in a loop) +measure.Delete(); +table.Measures.Where(m => m.IsHidden).ToList().ForEach(m => m.Delete()); +``` + +## Adding measures + +`AddMeasure()` creates a new measure on a table. All parameters except the first are optional. + +```csharp +var table = Model.Tables["Sales"]; + +// Simple measure +var m = table.AddMeasure("Revenue", "SUM('Sales'[Amount])"); +m.FormatString = "#,##0.00"; +m.Description = "Total sales amount"; + +// With display folder +var m2 = table.AddMeasure("Cost", "SUM('Sales'[Cost])", "Financial"); +``` + +## Adding columns + +```csharp +// Calculated column (DAX expression) +var cc = table.AddCalculatedColumn("Profit", "'Sales'[Amount] - 'Sales'[Cost]"); +cc.DataType = DataType.Decimal; +cc.FormatString = "#,##0.00"; + +// Data column (maps to a source column) +var dc = table.AddDataColumn("Region", "RegionName", "Geography", DataType.String); +``` + +## Adding hierarchies + +Pass columns as parameters to automatically create levels. + +```csharp +var dateTable = Model.Tables["Date"]; +var h = dateTable.AddHierarchy( + "Calendar", + "", + dateTable.Columns["Year"], + dateTable.Columns["Quarter"], + dateTable.Columns["Month"] +); +``` + +Or add levels one at a time: + +```csharp +var h = dateTable.AddHierarchy("Fiscal"); +h.AddLevel(dateTable.Columns["FiscalYear"]); +h.AddLevel(dateTable.Columns["FiscalQuarter"]); +h.AddLevel(dateTable.Columns["FiscalMonth"]); +``` + +## Adding calculated tables + +```csharp +var ct = Model.AddCalculatedTable("DateKey List", "VALUES('Date'[DateKey])"); +``` + +## Adding relationships + +`AddRelationship()` creates an empty relationship. You must set the columns explicitly. + +```csharp +var rel = Model.AddRelationship(); +rel.FromColumn = Model.Tables["Sales"].Columns["ProductKey"]; +rel.ToColumn = Model.Tables["Product"].Columns["ProductKey"]; +rel.CrossFilteringBehavior = CrossFilteringBehavior.OneDirection; +rel.IsActive = true; +``` + +## Cloning objects + +`Clone()` creates a copy with all properties, annotations and translations. + +```csharp +// Clone within the same table +var original = Model.AllMeasures.First(m => m.Name == "Revenue"); +var copy = original.Clone("Revenue Copy"); + +// Clone to a different table (with translations) +var copy2 = original.Clone("Revenue Copy", true, Model.Tables["Reporting"]); +``` + +## Generating measures from columns + +A common pattern: iterate selected columns and create derived measures. + +```csharp +foreach (var col in Selected.Columns) +{ + var m = col.Table.AddMeasure( + "Sum of " + col.Name, + "SUM(" + col.DaxObjectFullName + ")", + col.DisplayFolder + ); + m.FormatString = "0.00"; + col.IsHidden = true; +} +``` + +## Deleting objects + +Call `Delete()` on any named object to remove it. When deleting inside a loop, always call `.ToList()` first to avoid modifying the collection during iteration. + +```csharp +// Delete a single object +Model.AllMeasures.First(m => m.Name == "Temp").Delete(); + +// Delete multiple objects safely +Model.AllMeasures + .Where(m => m.HasAnnotation("DEPRECATED")) + .ToList() + .ForEach(m => m.Delete()); +``` + +## Common pitfalls + +> [!WARNING] +> - Always call `.ToList()` before deleting objects in a loop. Without it, modifying the collection during iteration causes an exception. +> - `AddRelationship()` creates an incomplete relationship. You must assign both `FromColumn` and `ToColumn` before the model validates. Failing to do so results in a validation error. +> - New objects have default property values. Set `DataType`, `FormatString`, `IsHidden` and other properties explicitly after creation. +> - `Clone()` copies all metadata including annotations, translations and perspective membership. If you do not want to inherit these, remove them after cloning. + +## See also + +- @useful-script-snippets +- @script-create-sum-measures-from-columns +- @how-to-navigate-tom-hierarchy +- @how-to-use-selected-object diff --git a/content/how-tos/scripting-check-object-types.md b/content/how-tos/scripting-check-object-types.md new file mode 100644 index 00000000..91f9c53c --- /dev/null +++ b/content/how-tos/scripting-check-object-types.md @@ -0,0 +1,142 @@ +--- +uid: how-to-check-object-types +title: How to Check Object Types +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Check Object Types + +The TOM hierarchy uses inheritance. `Column` is an abstract base with subtypes `DataColumn`, `CalculatedColumn` and `CalculatedTableColumn`. `Table` has the subtype `CalculationGroupTable`. This article shows how to test and filter by type in C# scripts and Dynamic LINQ. + +## Quick reference + +```csharp +// Pattern matching (preferred) +if (col is CalculatedColumn cc) + Info(cc.Expression); + +// Filter a collection by type +var calcCols = Model.AllColumns.OfType(); +var calcGroups = Model.Tables.OfType(); + +// Runtime type name +string typeName = obj.GetType().Name; // "DataColumn", "Measure", etc. + +// Null-safe cast +var dc = col as DataColumn; +if (dc != null) { /* use dc */ } +``` + +## Type hierarchy + +The key inheritance relationships in the TOM wrapper: + +| Base type | Subtypes | +|---|---| +| `Column` | `DataColumn`, `CalculatedColumn`, `CalculatedTableColumn` | +| `Table` | `CalculatedTable`, `CalculationGroupTable` | +| `Partition` | `MPartition`, `EntityPartition`, `PolicyRangePartition` | +| `DataSource` | `ProviderDataSource`, `StructuredDataSource` | + +## Filtering collections by type + +`OfType()` filters and casts in one step. Prefer it over `Where(x => x is T)`. + +```csharp +// All calculated columns in the model +var calculatedColumns = Model.AllColumns.OfType(); + +// All M partitions (Power Query) +var mPartitions = Model.AllPartitions.OfType(); + +// All calculation group tables +var calcGroups = Model.Tables.OfType(); + +// All regular tables (exclude calculation groups) +var regularTables = Model.Tables.Where(t => t is not CalculationGroupTable); +``` + +## Pattern matching with is + +Use C# pattern matching to test and cast in a single expression. + +```csharp +foreach (var col in Model.AllColumns) +{ + if (col is CalculatedColumn cc) + Info($"{cc.Name}: {cc.Expression}"); + else if (col is DataColumn dc) + Info($"{dc.Name}: data column in {dc.Table.Name}"); +} +``` + +## Checking ObjectType enum + +Every TOM object has an `ObjectType` property that returns an enum value. This identifies the base type, not the subtype. + +```csharp +foreach (var obj in Selected.Objects) +{ + switch (obj.ObjectType) + { + case ObjectType.Measure: /* ... */ break; + case ObjectType.Column: /* ... */ break; + case ObjectType.Table: /* ... */ break; + case ObjectType.Hierarchy: /* ... */ break; + } +} +``` + +> [!WARNING] +> `ObjectType` does not distinguish subtypes. A `CalculatedColumn` and a `DataColumn` both return `ObjectType.Column`. Use `is` or `OfType()` when you need subtype-level checks. + +## Checking partition source type + +For partitions, use the `SourceType` property to distinguish between storage modes without type-casting. + +```csharp +foreach (var p in Model.AllPartitions) +{ + switch (p.SourceType) + { + case PartitionSourceType.M: /* Power Query */ break; + case PartitionSourceType.Calculated: /* DAX calc table */ break; + case PartitionSourceType.Entity: /* Direct Lake */ break; + case PartitionSourceType.Query: /* Legacy SQL */ break; + } +} +``` + +## Dynamic LINQ equivalent + +In BPA rules, type filtering works differently. Set the rule's **Applies to** scope to target a specific object type. Within the expression, use `ObjectTypeName` for the base type name as a string. + +``` +// BPA expression context: the rule "Applies to" determines the object type +// No C#-style type casting is available in Dynamic LINQ + +// Check the object type name (rarely needed since scope handles this) +ObjectTypeName = "Measure" +ObjectTypeName = "Column" +``` + +For subtypes like calculated columns, set the BPA rule scope to **Calculated Columns** rather than trying to filter by type in the expression. + +## Common pitfalls + +> [!IMPORTANT] +> - `Column` is abstract. You cannot create an instance of `Column` directly. Use `table.AddDataColumn()` or `table.AddCalculatedColumn()` on regular tables. `AddCalculatedTableColumn()` is only available on `CalculatedTable`. +> - `OfType()` both filters and casts. `Where(x => x is T)` only filters, leaving you with the base type. Prefer `OfType()` when you need access to subtype properties. +> - `ObjectType` and `SourceType` are base-level checks. For subtype-specific logic (e.g., accessing `Expression` on a `CalculatedColumn`), use `is` or `OfType()`. + +## See also + +- @csharp-scripts +- @using-bpa-sample-rules-expressions +- @how-to-navigate-tom-hierarchy diff --git a/content/how-tos/scripting-dynamic-linq-vs-csharp.md b/content/how-tos/scripting-dynamic-linq-vs-csharp.md new file mode 100644 index 00000000..4e139015 --- /dev/null +++ b/content/how-tos/scripting-dynamic-linq-vs-csharp.md @@ -0,0 +1,197 @@ +--- +uid: how-to-dynamic-linq-vs-csharp-linq +title: How Dynamic LINQ Differs from C# LINQ +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How Dynamic LINQ Differs from C# LINQ + +C# scripts use standard C# LINQ with lambda expressions. Best Practice Analyzer (BPA) rules and Explorer tree filters use [Dynamic LINQ](https://dynamic-linq.net/expression-language), a string-based expression language with different syntax. This article is a translation guide between the two. + +## Where each is used + +| Context | Syntax | +|---|---| +| C# scripts and macros | C# LINQ | +| BPA rule expressions | Dynamic LINQ | +| BPA fix expressions | Dynamic LINQ (with `it.` prefix for assignments) | +| **TOM Explorer** tree filter (`:` prefix) | Dynamic LINQ | + +## Syntax comparison + +| Concept | C# LINQ (scripts) | Dynamic LINQ (BPA / filter) | +|---|---|---| +| Boolean AND | `&&` | `and` | +| Boolean OR | `\|\|` | `or` | +| Boolean NOT | `!` | `not` | +| Equals | `==` | `=` | +| Not equals | `!=` | `!=` or `<>` | +| Greater/less | `>`, `<`, `>=`, `<=` | `>`, `<`, `>=`, `<=` | +| String contains | `m.Name.Contains("Sales")` | `Name.Contains("Sales")` | +| String starts with | `m.Name.StartsWith("Sum")` | `Name.StartsWith("Sum")` | +| String ends with | `m.Name.EndsWith("YTD")` | `Name.EndsWith("YTD")` | +| Null/empty check | `string.IsNullOrEmpty(m.Description)` | `String.IsNullOrEmpty(Description)` | +| Whitespace check | `string.IsNullOrWhiteSpace(m.Description)` | `String.IsNullOrWhitespace(Description)` | +| Regex match | `Regex.IsMatch(m.Name, "pattern")` | `RegEx.IsMatch(Name, "pattern")` | + +## Enum comparison + +C# uses typed enum values. Dynamic LINQ uses string representations. + +| C# LINQ | Dynamic LINQ | +|---|---| +| `c.DataType == DataType.String` | `DataType = "String"` | +| `p.SourceType == PartitionSourceType.M` | `SourceType = "M"` | +| `p.Mode == ModeType.DirectLake` | `Mode = "DirectLake"` | +| `r.CrossFilteringBehavior == CrossFilteringBehavior.BothDirections` | `CrossFilteringBehavior = "BothDirections"` | + +## Lambda expressions vs implicit context + +C# LINQ uses explicit lambda parameters. Dynamic LINQ evaluates properties on an implicit `it` context object. + +```csharp +// C# LINQ: explicit lambda parameter +Model.AllMeasures.Where(m => m.IsHidden && m.Description == "") + +// Dynamic LINQ: implicit "it" -- properties are accessed directly +IsHidden and Description = "" +``` + +## Parent object navigation + +Both use dot notation, but C# requires the lambda parameter. + +```csharp +// C# LINQ +Model.AllMeasures.Where(m => m.Table.IsHidden) + +// Dynamic LINQ +Table.IsHidden +``` + +## Collection methods + +C# LINQ uses lambdas inside collection methods. Dynamic LINQ uses implicit context within collection methods, with `outerIt` to reference the parent object. + +```csharp +// C# LINQ: count columns with no description +Model.Tables.Where(t => t.Columns.Count(c => c.Description == "") > 5) + +// Dynamic LINQ: same logic +Columns.Count(Description = "") > 5 +``` + +### The outerIt keyword + +Inside a nested collection method in Dynamic LINQ, `it` refers to the inner object (e.g., a column). Use `outerIt` to reference the outer object (e.g., the table). + +``` +// BPA rule on Tables: find tables where any column name matches the table name +Columns.Any(Name = outerIt.Name) +``` + +In C#, this is handled naturally by lambda closure: + +```csharp +// C# equivalent +Model.Tables.Where(t => t.Columns.Any(c => c.Name == t.Name)) +``` + +## Type filtering + +C# uses `OfType()` or `is`. Dynamic LINQ relies on the BPA rule's **Applies to** scope setting. + +| C# LINQ | Dynamic LINQ approach | +|---|---| +| `Model.AllColumns.OfType()` | Set BPA rule scope to **Calculated Columns** | +| `Model.Tables.OfType()` | Set BPA rule scope to **Calculation Group Tables** | +| `obj is Measure` | Rule scope handles this; use `ObjectTypeName = "Measure"` if needed | + +## Dependency properties + +These work identically in both syntaxes, but Dynamic LINQ omits the object prefix. + +| C# LINQ | Dynamic LINQ | +|---|---| +| `m.ReferencedBy.Count == 0` | `ReferencedBy.Count = 0` | +| `m.DependsOn.Any()` | `DependsOn.Any()` | +| `c.UsedInRelationships.Any()` | `UsedInRelationships.Any()` | +| `c.ReferencedBy.AnyVisible` | `ReferencedBy.AnyVisible` | + +## Annotation methods + +```csharp +// C# LINQ +Model.AllMeasures.Where(m => m.HasAnnotation("AUTOGEN")) + +// Dynamic LINQ +HasAnnotation("AUTOGEN") +``` + +| C# LINQ | Dynamic LINQ | +|---|---| +| `m.GetAnnotation("key") == "value"` | `GetAnnotation("key") = "value"` | +| `m.HasAnnotation("key")` | `HasAnnotation("key")` | + +## Perspective and translation indexers + +```csharp +// C# LINQ +Model.AllMeasures.Where(m => m.InPerspective["Sales"]) + +// Dynamic LINQ +InPerspective["Sales"] +``` + +| C# LINQ | Dynamic LINQ | +|---|---| +| `m.InPerspective["Sales"]` | `InPerspective["Sales"]` | +| `!m.InPerspective["Sales"]` | `not InPerspective["Sales"]` | +| `string.IsNullOrEmpty(m.TranslatedNames["da-DK"])` | `String.IsNullOrEmpty(TranslatedNames["da-DK"])` | + +## BPA fix expressions + +Fix expressions use `it.` as the assignment target. The left side of the assignment refers to the object that violated the rule. + +``` +// Set IsHidden to true on the violating object +it.IsHidden = true + +// Set description +it.Description = "TODO: Add description" +``` + +There is no C# LINQ equivalent -- fix expressions are a BPA-specific feature. + +## Complete example: same rule in both syntaxes + +**Goal:** Find measures that are hidden, have no references and have no description. + +C# script: +```csharp +var unused = Model.AllMeasures + .Where(m => m.IsHidden + && m.ReferencedBy.Count == 0 + && string.IsNullOrWhiteSpace(m.Description)); + +foreach (var m in unused) + Info(m.DaxObjectFullName); +``` + +BPA rule expression (applies to Measures): +``` +IsHidden and ReferencedBy.Count = 0 and String.IsNullOrWhitespace(Description) +``` + +## See also + +- @using-bpa-sample-rules-expressions +- @advanced-filtering-explorer-tree +- @bpa +- @how-to-filter-query-objects-linq diff --git a/content/how-tos/scripting-filter-query-linq.md b/content/how-tos/scripting-filter-query-linq.md new file mode 100644 index 00000000..f7f32b86 --- /dev/null +++ b/content/how-tos/scripting-filter-query-linq.md @@ -0,0 +1,165 @@ +--- +uid: how-to-filter-query-objects-linq +title: How to Filter and Query Objects with LINQ +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Filter and Query Objects with LINQ + +C# scripts use standard LINQ methods to filter, search and transform TOM object collections. This article covers the essential LINQ patterns for querying semantic model objects. + +## Quick reference + +```csharp +// Filter +Model.AllMeasures.Where(m => m.Expression.Contains("CALCULATE")) + +// Find one +Model.Tables.First(t => t.Name == "Sales") +Model.Tables.FirstOrDefault(t => t.Name == "Sales") // returns null if not found + +// Existence checks +table.Measures.Any(m => m.IsHidden) // true if at least one +table.Columns.All(c => c.Description != "") // true if every one + +// Count +Model.AllColumns.Count(c => c.DataType == DataType.String) + +// Project +Model.AllMeasures.Select(m => m.Name).ToList() + +// Sort +Model.AllMeasures.OrderBy(m => m.Name) +Model.AllMeasures.OrderByDescending(m => m.Table.Name) + +// Mutate +Model.AllMeasures.Where(m => m.FormatString == "").ForEach(m => m.FormatString = "0.00") + +// Type filter +Model.AllColumns.OfType() + +// Materialize before deleting +table.Measures.Where(m => m.IsHidden).ToList().ForEach(m => m.Delete()) +``` + +## Filtering with Where + +`Where()` returns all objects matching a predicate. Chain multiple conditions with `&&` and `||`. + +```csharp +// Measures that use CALCULATE and are hidden +var matches = Model.AllMeasures + .Where(m => m.Expression.Contains("CALCULATE") && m.IsHidden); + +// Columns with no description in a specific table +var undocumented = Model.Tables["Sales"].Columns + .Where(c => string.IsNullOrEmpty(c.Description)); +``` + +## Finding a single object + +`First()` returns the first match or throws if none exist. `FirstOrDefault()` returns null instead of throwing. + +```csharp +// Throws if "Sales" does not exist +var sales = Model.Tables.First(t => t.Name == "Sales"); + +// Returns null if not found (safe) +var table = Model.Tables.FirstOrDefault(t => t.Name == "Sales"); +if (table == null) { Error("Table not found."); return; } +``` + +## Existence and count checks + +```csharp +// Does any measure reference CALCULATE? +bool usesCalc = Model.AllMeasures.Any(m => m.Expression.Contains("CALCULATE")); + +// Are all columns documented? +bool allDocs = table.Columns.All(c => !string.IsNullOrEmpty(c.Description)); + +// How many string columns? +int count = Model.AllColumns.Count(c => c.DataType == DataType.String); +``` + +## Projection with Select + +`Select()` transforms each element. Use it to extract property values or build new structures. + +```csharp +// List of measure names +var names = Model.AllMeasures.Select(m => m.Name).ToList(); + +// Table name + measure count pairs +var summary = Model.Tables.Select(t => new { t.Name, Count = t.Measures.Count() }); +``` + +## Mutation with ForEach + +The `ForEach()` extension method applies an action to every element. + +```csharp +// Set format string on all currency measures +Model.AllMeasures + .Where(m => m.Name.EndsWith("Amount")) + .ForEach(m => m.FormatString = "#,##0.00"); + +// Move all measures in a table to a display folder +Model.Tables["Sales"].Measures.ForEach(m => m.DisplayFolder = "Sales Metrics"); +``` + +## Materializing with ToList before deletion + +When you delete objects inside a loop, you modify the collection being iterated. This causes a collection-modified exception. Always call `.ToList()` first to create a snapshot. + +```csharp +// WRONG: modifying collection during iteration +table.Measures.Where(m => m.IsHidden).ForEach(m => m.Delete()); // throws + +// CORRECT: materialize first, then delete +table.Measures.Where(m => m.IsHidden).ToList().ForEach(m => m.Delete()); +``` + +> [!WARNING] +> Always call `.ToList()` before `.ForEach(x => x.Delete())` or any operation that adds/removes objects from the collection being iterated. + +## Combining collections + +Use `Concat()` to merge collections and `Distinct()` to remove duplicates. + +```csharp +// All hidden objects (measures + columns) in a table +var hidden = table.Measures.Where(m => m.IsHidden).Cast() + .Concat(table.Columns.Where(c => c.IsHidden).Cast()); +``` + +## Dynamic LINQ equivalent + +In BPA rule expressions, the syntax differs from C# LINQ. Dynamic LINQ has no lambda arrows, uses keyword operators and compares enums as strings. + +| C# LINQ (scripts) | Dynamic LINQ (BPA / Explorer filter) | +|---|---| +| `m.IsHidden` | `IsHidden` | +| `m.DataType == DataType.String` | `DataType = "String"` | +| `&&` / `\|\|` / `!` | `and` / `or` / `not` | +| `==` / `!=` | `=` / `!=` or `<>` | +| `table.Columns.Count(c => c.IsHidden)` | `Columns.Count(IsHidden)` | +| `table.Measures.Any(m => m.IsHidden)` | `Measures.Any(IsHidden)` | +| `table.Columns.All(c => c.Description != "")` | `Columns.All(Description != "")` | +| `string.IsNullOrEmpty(m.Description)` | `String.IsNullOrEmpty(Description)` | + +> [!NOTE] +> Dynamic LINQ expressions evaluate against a single object in context. There is no equivalent to `Model.AllMeasures` or cross-table queries. Each BPA rule runs its expression once per object in its scope. + +## See also + +- @advanced-scripting +- @using-bpa-sample-rules-expressions +- @how-to-navigate-tom-hierarchy +- @how-to-dynamic-linq-vs-csharp-linq diff --git a/content/how-tos/scripting-navigate-tom-hierarchy.md b/content/how-tos/scripting-navigate-tom-hierarchy.md new file mode 100644 index 00000000..86810988 --- /dev/null +++ b/content/how-tos/scripting-navigate-tom-hierarchy.md @@ -0,0 +1,150 @@ +--- +uid: how-to-navigate-tom-hierarchy +title: How to Navigate the TOM Object Hierarchy +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Navigate the TOM Object Hierarchy + +Every C# script starts from the `Model` object, which is the root of the Tabular Object Model (TOM) hierarchy. This article shows how to reach any object in a semantic model. + +## Quick reference + +```csharp +// Direct path to a specific object +var table = Model.Tables["Sales"]; +var measure = Model.Tables["Sales"].Measures["Revenue"]; +var column = Model.Tables["Sales"].Columns["Amount"]; +var hierarchy = Model.Tables["Date"].Hierarchies["Calendar"]; +var partition = Model.Tables["Sales"].Partitions["Sales-Part1"]; + +// Cross-table shortcut collections +Model.AllMeasures // every measure across all tables +Model.AllColumns // every column across all tables +Model.AllHierarchies // every hierarchy across all tables +Model.AllPartitions // every partition across all tables +Model.AllLevels // every level across all hierarchies +Model.AllCalculationItems // every calculation item across all calculation groups + +// Top-level collections +Model.Tables // all tables +Model.Relationships // all relationships +Model.Perspectives // all perspectives +Model.Roles // all security roles +Model.Cultures // all translation cultures +Model.DataSources // all data sources +Model.CalculationGroups // all calculation group tables +``` + +## Accessing objects by name + +Use the indexer `["name"]` on any collection to retrieve an object by its exact name. This throws an exception if the name does not exist. + +```csharp +var salesTable = Model.Tables["Sales"]; +var revenueM = salesTable.Measures["Revenue"]; +var amountCol = salesTable.Columns["Amount"]; +``` + +Use `FirstOrDefault()` when the object may not exist: + +```csharp +var table = Model.Tables.FirstOrDefault(t => t.Name == "Sales"); +if (table == null) { Error("Table not found"); return; } +``` + +## Navigating from child to parent + +Every object holds a reference to its parent. Use these to walk up the hierarchy. + +```csharp +var measure = Model.AllMeasures.First(m => m.Name == "Revenue"); +var parentTable = measure.Table; // Table that contains this measure +var model = measure.Model; // The Model root + +var level = Model.AllLevels.First(); +var hierarchy = level.Hierarchy; // parent hierarchy +var table = level.Table; // parent table (via hierarchy) +``` + +## Navigating table children + +Each `Table` exposes typed collections for its child objects. + +```csharp +var table = Model.Tables["Sales"]; + +table.Columns // ColumnCollection +table.Measures // MeasureCollection +table.Hierarchies // HierarchyCollection +table.Partitions // PartitionCollection +``` + +## Searching with predicates + +Use LINQ methods on any collection to find objects by property values. + +```csharp +// Find all fact tables +var factTables = Model.Tables.Where(t => t.Name.StartsWith("Fact")); + +// Find all hidden measures +var hiddenMeasures = Model.AllMeasures.Where(m => m.IsHidden); + +// Find the first column with a specific data type +var dateCol = Model.AllColumns.First(c => c.DataType == DataType.DateTime); +``` + +## Calculation groups and calculation items + +Calculation group tables are a subtype of `Table`. Access them through `Model.CalculationGroups` and iterate their items. + +```csharp +foreach (var cg in Model.CalculationGroups) +{ + foreach (var item in cg.CalculationItems) + { + // item.Name, item.Expression, item.Ordinal + } +} +``` + +## Relationships + +Relationships live on the `Model`, not on tables. Each relationship references its from/to columns and tables. + +```csharp +foreach (var rel in Model.Relationships) +{ + var fromTable = rel.FromTable; + var fromColumn = rel.FromColumn; + var toTable = rel.ToTable; + var toColumn = rel.ToColumn; +} +``` + +## Dynamic LINQ equivalent + +In Best Practice Analyzer (BPA) rule expressions and **TOM Explorer** tree filters, you access properties directly on the object in context. Parent navigation uses dot notation. + +| C# script | Dynamic LINQ (BPA) | +|---|---| +| `measure.Table.Name` | `Table.Name` | +| `column.Table.IsHidden` | `Table.IsHidden` | +| `table.Columns.Count()` | `Columns.Count()` | +| `table.Measures.Any(m => m.IsHidden)` | `Measures.Any(IsHidden)` | + +> [!NOTE] +> Dynamic LINQ expressions in BPA rules evaluate against a single object at a time. You do not have access to `Model` or cross-table collections. Use the rule's **Applies to** scope to select which object type the expression runs against. + +## See also + +- @csharp-scripts +- @advanced-scripting +- @how-to-filter-query-objects-linq diff --git a/content/how-tos/scripting-perspectives-translations.md b/content/how-tos/scripting-perspectives-translations.md new file mode 100644 index 00000000..b71c0219 --- /dev/null +++ b/content/how-tos/scripting-perspectives-translations.md @@ -0,0 +1,131 @@ +--- +uid: how-to-work-with-perspectives-translations +title: How to Work with Perspectives and Translations +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Work with Perspectives and Translations + +Perspectives control which objects are visible in specific client views. Translations (cultures) provide localized names, descriptions and display folders. Both use indexer properties on TOM objects. + +## Quick reference + +```csharp +// Perspectives +measure.InPerspective["Sales"] = true; // include in perspective +measure.InPerspective["Sales"] = false; // exclude from perspective +bool isIn = measure.InPerspective["Sales"]; // check membership + +// Translations +measure.TranslatedNames["da-DK"] = "Omsætning"; // set translated name +measure.TranslatedDescriptions["da-DK"] = "..."; // set translated description +measure.TranslatedDisplayFolders["da-DK"] = "Salg"; // set translated folder + +string name = measure.TranslatedNames["da-DK"]; // read translation (empty string if unset) + +// Iterate cultures +foreach (var culture in Model.Cultures) + Info(culture.Name); // "da-DK", "de-DE", etc. +``` + +## Setting perspective membership + +The `InPerspective` indexer is available on tables, columns, measures and hierarchies (any `ITabularPerspectiveObject`). + +```csharp +// Add all measures in a table to a perspective +Model.Tables["Sales"].Measures.ForEach(m => m.InPerspective["Sales Report"] = true); + +// Remove a table and its children from a perspective +var table = Model.Tables["Internal"]; +table.InPerspective["Sales Report"] = false; +``` + +## Copying perspective membership + +Copy the perspective visibility from one object to another. + +```csharp +var source = Model.AllMeasures.First(m => m.Name == "Revenue"); +var target = Model.AllMeasures.First(m => m.Name == "Revenue YTD"); + +foreach (var p in Model.Perspectives) + target.InPerspective[p.Name] = source.InPerspective[p.Name]; +``` + +## Creating and removing perspectives + +```csharp +// Create a new perspective +var p = Model.AddPerspective("Executive Dashboard"); + +// Remove a perspective +Model.Perspectives["Old View"].Delete(); +``` + +## Setting translations + +Translation indexers are available on objects implementing `ITranslatableObject` (tables, columns, measures, hierarchies, levels). Display folder translations require `IFolderObject` (measures, columns, hierarchies). + +```csharp +var m = Model.AllMeasures.First(m => m.Name == "Revenue"); +m.TranslatedNames["da-DK"] = "Omsætning"; +m.TranslatedDescriptions["da-DK"] = "Total omsætning i DKK"; +m.TranslatedDisplayFolders["da-DK"] = "Salg"; +``` + +## Finding missing translations + +```csharp +foreach (var culture in Model.Cultures) +{ + var missing = Model.AllMeasures + .Where(m => string.IsNullOrEmpty(m.TranslatedNames[culture.Name])); + + Info($"{culture.Name}: {missing.Count()} measures without translated names"); +} +``` + +## Bulk-setting translations from a naming convention + +```csharp +// Copy the default name as the translation for cultures that are missing it +foreach (var culture in Model.Cultures) +{ + Model.AllMeasures + .Where(m => string.IsNullOrEmpty(m.TranslatedNames[culture.Name])) + .ForEach(m => m.TranslatedNames[culture.Name] = m.Name); +} +``` + +## Creating and removing cultures + +```csharp +// Add a new culture +var culture = Model.AddTranslation("fr-FR"); + +// Remove a culture +Model.Cultures["fr-FR"].Delete(); +``` + +## Dynamic LINQ equivalent + +In BPA rule expressions, perspective and translation indexers are accessed directly. + +| C# script | Dynamic LINQ (BPA) | +|---|---| +| `m.InPerspective["Sales"]` | `InPerspective["Sales"]` | +| `!m.InPerspective["Sales"]` | `not InPerspective["Sales"]` | +| `string.IsNullOrEmpty(m.TranslatedNames["da-DK"])` | `String.IsNullOrEmpty(TranslatedNames["da-DK"])` | + +## See also + +- @perspectives-translations +- @import-export-translations +- @how-to-navigate-tom-hierarchy diff --git a/content/how-tos/scripting-ui-helpers.md b/content/how-tos/scripting-ui-helpers.md new file mode 100644 index 00000000..5356f995 --- /dev/null +++ b/content/how-tos/scripting-ui-helpers.md @@ -0,0 +1,401 @@ +--- +uid: how-to-use-script-ui-helpers +title: How to Use Script UI Helpers +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Use Script UI Helpers + +Tabular Editor provides helper methods for user interaction in scripts: displaying output, showing messages, prompting for object selection, evaluating DAX and building custom dialogs. In the desktop UI, these show graphical dialogs. In the CLI, they write to the console. + +## Quick reference + +```csharp +// Messages +Info("Operation completed."); // informational popup +Warning("This might take a while."); // warning popup +Error("No valid selection."); return; // error popup + stop script + +// Output +Output(measure); // property grid for a TOM object +Output(listOfMeasures); // list view with property grid +Output(dataTable); // sortable/filterable grid +Output("Hello"); // simple dialog + +// Object selection dialogs +var table = SelectTable(); // pick a table +var column = SelectColumn(table.Columns); // pick from filtered columns +var measure = SelectMeasure(); // pick a measure +var obj = SelectObject(Model.DataSources); // generic selection +var items = SelectObjects(collection); // multi-select + +// Evaluate DAX +var result = EvaluateDax("COUNTROWS('Sales')"); // run DAX on connected model +``` + +## Messages: Info, Warning, Error + +Use these for simple communication. `Error()` does not stop script execution by itself -- follow it with `return` if you want to halt. + +```csharp +if (Selected.Measures.Count() == 0) +{ + Error("Select at least one measure before running this script."); + return; +} + +// ... do work ... +Info("Updated " + Selected.Measures.Count() + " measures."); +``` + +## Output + +`Output()` behaves differently depending on the argument type: + +| Argument type | Behavior | +|---|---| +| TOM object (e.g., `Measure`) | Property grid allowing inspection and editing | +| `IEnumerable` | List view with property grid | +| `DataTable` | Sortable, filterable grid | +| String or primitive | Simple message dialog | + +### DataTable for structured output + +```csharp +using System.Data; + +var result = new DataTable(); +result.Columns.Add("Measure"); +result.Columns.Add("Table"); +result.Columns.Add("Token Count", typeof(int)); + +foreach (var m in Model.AllMeasures) +{ + result.Rows.Add(m.DaxObjectName, m.Table.Name, m.Tokenize().Count); +} + +Output(result); +``` + +> [!TIP] +> Specify `typeof(int)` or `typeof(double)` for numeric columns to enable correct sorting in the output grid. + +## Object selection dialogs + +Selection helpers show a list dialog and return the user's choice. They throw an exception if the user cancels. Wrap them in try/catch. + +```csharp +try +{ + var table = SelectTable(Model.Tables, null, "Select a table:"); + var column = SelectColumn( + table.Columns.Where(c => c.DataType == DataType.DateTime), + null, + "Select a date column:" + ); + Info($"You selected {table.Name}.{column.Name}"); +} +catch +{ + Error("Selection cancelled."); +} +``` + +### Multi-select + +`SelectObjects()` allows the user to pick multiple objects. + +```csharp +try +{ + var measures = SelectObjects( + Model.AllMeasures.Where(m => m.IsHidden), + null, + "Select measures to unhide:" + ); + foreach (var m in measures) + m.IsHidden = false; +} +catch +{ + Error("No selection made."); +} +``` + +## Evaluating DAX + +`EvaluateDax()` executes a DAX expression against the connected model and returns the result. + +```csharp +var rowCount = Convert.ToInt64(EvaluateDax("COUNTROWS('Sales')")); +Info($"Sales table has {rowCount:N0} rows."); + +// Return a table result +var result = EvaluateDax("ALL('Product'[Category])"); +Output(result); +``` + +> [!NOTE] +> `EvaluateDax()` requires an active connection to an Analysis Services or Power BI instance. It does not work when editing a model offline. + +## Guard clause patterns + +Validate preconditions before the script runs. + +```csharp +// Require at least one column or measure +if (Selected.Columns.Count() == 0 && Selected.Measures.Count() == 0) +{ + Error("Select at least one column or measure."); + return; +} + +// Smart single-or-select pattern +DataSource ds; +if (Selected.DataSources.Count() == 1) + ds = Selected.DataSource; +else + ds = SelectObject(Model.DataSources, null, "Select a data source:"); +``` + +## Custom WinForms dialogs + +For more complex input, build WinForms dialogs. Use `TableLayoutPanel` and `FlowLayoutPanel` with `AutoSize` for proper scaling across DPI settings. + +> [!WARNING] +> Do not use manual pixel positioning with `Location = new Point(x, y)` for custom dialogs. This approach breaks at non-standard DPI settings. Use layout panels instead. + +### Simple prompt dialog + +```csharp +using System.Windows.Forms; +using System.Drawing; + +WaitFormVisible = false; + +using (var form = new Form()) +{ + form.Text = "Enter a value"; + form.AutoSize = true; + form.AutoSizeMode = AutoSizeMode.GrowAndShrink; + form.FormBorderStyle = FormBorderStyle.FixedDialog; + form.MaximizeBox = false; + form.MinimizeBox = false; + form.StartPosition = FormStartPosition.CenterParent; + form.Padding = new Padding(20); + + var layout = new TableLayoutPanel { + ColumnCount = 1, Dock = DockStyle.Fill, + AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink + }; + form.Controls.Add(layout); + + layout.Controls.Add(new Label { Text = "Display folder name:", AutoSize = true }); + var textBox = new TextBox { Width = 300, Text = "New Folder" }; + layout.Controls.Add(textBox); + + var buttons = new FlowLayoutPanel { + FlowDirection = FlowDirection.LeftToRight, + Dock = DockStyle.Fill, AutoSize = true, + Padding = new Padding(0, 10, 0, 0) + }; + var okBtn = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK }; + var cancelBtn = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + buttons.Controls.AddRange(new Control[] { okBtn, cancelBtn }); + layout.Controls.Add(buttons); + + form.AcceptButton = okBtn; + form.CancelButton = cancelBtn; + + if (form.ShowDialog() == DialogResult.OK) + { + Selected.Measures.ForEach(m => m.DisplayFolder = textBox.Text); + Info("Updated display folder to: " + textBox.Text); + } +} +``` + +### Multi-field form with validation + +```csharp +using System.Windows.Forms; +using System.Drawing; + +WaitFormVisible = false; + +using (var form = new Form()) +{ + form.Text = "Create Measure"; + form.AutoSize = true; + form.AutoSizeMode = AutoSizeMode.GrowAndShrink; + form.StartPosition = FormStartPosition.CenterParent; + form.FormBorderStyle = FormBorderStyle.FixedDialog; + form.MaximizeBox = false; + form.MinimizeBox = false; + form.Padding = new Padding(20); + + var layout = new TableLayoutPanel { + ColumnCount = 1, Dock = DockStyle.Fill, + AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink + }; + form.Controls.Add(layout); + + // Name field + layout.Controls.Add(new Label { Text = "Measure name:", AutoSize = true }); + var nameBox = new TextBox { Width = 400 }; + layout.Controls.Add(nameBox); + + // Expression field + layout.Controls.Add(new Label { + Text = "DAX expression:", AutoSize = true, + Padding = new Padding(0, 10, 0, 0) + }); + var exprBox = new TextBox { Width = 400, Height = 80, Multiline = true }; + layout.Controls.Add(exprBox); + + // Buttons + var buttons = new FlowLayoutPanel { + FlowDirection = FlowDirection.LeftToRight, + Dock = DockStyle.Fill, AutoSize = true, + Padding = new Padding(0, 10, 0, 0) + }; + var okBtn = new Button { + Text = "OK", AutoSize = true, + DialogResult = DialogResult.OK, Enabled = false + }; + var cancelBtn = new Button { + Text = "Cancel", AutoSize = true, + DialogResult = DialogResult.Cancel + }; + buttons.Controls.AddRange(new Control[] { okBtn, cancelBtn }); + layout.Controls.Add(buttons); + + form.AcceptButton = okBtn; + form.CancelButton = cancelBtn; + + // Enable OK only when both fields have content + EventHandler validate = (s, e) => + okBtn.Enabled = !string.IsNullOrWhiteSpace(nameBox.Text) + && !string.IsNullOrWhiteSpace(exprBox.Text); + nameBox.TextChanged += validate; + exprBox.TextChanged += validate; + + if (form.ShowDialog() == DialogResult.OK) + { + var table = Selected.Table; + table.AddMeasure(nameBox.Text.Trim(), exprBox.Text.Trim()); + Info("Created measure: " + nameBox.Text.Trim()); + } +} +``` + +### Scope selection dialog (reusable class) + +For scripts that need a choice between operating on selected objects or all objects, create a reusable dialog class. + +```csharp +using System.Windows.Forms; +using System.Drawing; + +public class ScopeDialog : Form +{ + public enum ScopeOption { OnlySelected, All, Cancel } + public ScopeOption SelectedOption { get; private set; } + + public ScopeDialog(int selectedCount, int totalCount) + { + Text = "Choose scope"; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Padding = new Padding(20); + + var layout = new TableLayoutPanel { + ColumnCount = 1, Dock = DockStyle.Fill, + AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink + }; + Controls.Add(layout); + + layout.Controls.Add(new Label { + Text = $"{selectedCount} object(s) selected out of {totalCount} total.", + AutoSize = true + }); + + var buttons = new FlowLayoutPanel { + FlowDirection = FlowDirection.LeftToRight, + Dock = DockStyle.Fill, AutoSize = true, + Padding = new Padding(0, 15, 0, 0) + }; + + var btnSelected = new Button { + Text = "Only selected", AutoSize = true, + DialogResult = DialogResult.OK + }; + btnSelected.Click += (s, e) => SelectedOption = ScopeOption.OnlySelected; + + var btnAll = new Button { + Text = "All objects", AutoSize = true, + DialogResult = DialogResult.Yes + }; + btnAll.Click += (s, e) => SelectedOption = ScopeOption.All; + + var btnCancel = new Button { + Text = "Cancel", AutoSize = true, + DialogResult = DialogResult.Cancel + }; + btnCancel.Click += (s, e) => SelectedOption = ScopeOption.Cancel; + + buttons.Controls.AddRange(new Control[] { btnSelected, btnAll, btnCancel }); + layout.Controls.Add(buttons); + + AcceptButton = btnSelected; + CancelButton = btnCancel; + } +} + +// Usage: +WaitFormVisible = false; +using (var dialog = new ScopeDialog(Selected.Measures.Count(), Model.AllMeasures.Count())) +{ + dialog.ShowDialog(); + switch (dialog.SelectedOption) + { + case ScopeDialog.ScopeOption.OnlySelected: + Selected.Measures.ForEach(m => m.FormatString = "#,##0.00"); + break; + case ScopeDialog.ScopeOption.All: + Model.AllMeasures.ForEach(m => m.FormatString = "#,##0.00"); + break; + case ScopeDialog.ScopeOption.Cancel: + break; + } +} +``` + +### Key rules for scaling-safe dialogs + +- Set `AutoSize = true` and `AutoSizeMode = AutoSizeMode.GrowAndShrink` on the form. +- Use `TableLayoutPanel` (vertical stacking) and `FlowLayoutPanel` (horizontal button rows) instead of manual coordinates. +- Set `FormBorderStyle = FormBorderStyle.FixedDialog` and disable maximize/minimize. +- Set `StartPosition = FormStartPosition.CenterParent`. +- Always set `AcceptButton` and `CancelButton` for keyboard support (Enter/Escape). +- Call `WaitFormVisible = false` before showing a dialog to hide the "Running Macro" spinner. +- Wrap the form in a `using` statement for proper disposal. + +## See also + +- @script-helper-methods +- @script-output-things +- @csharp-scripts +- @script-implement-incremental-refresh +- @script-find-replace +- @script-convert-dlol-to-import diff --git a/content/how-tos/scripting-use-selected-object.md b/content/how-tos/scripting-use-selected-object.md new file mode 100644 index 00000000..5f8cacc3 --- /dev/null +++ b/content/how-tos/scripting-use-selected-object.md @@ -0,0 +1,155 @@ +--- +uid: how-to-use-selected-object +title: How to Use the Selected Object +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Use the Selected Object + +The `Selected` object provides access to whatever is currently selected in the **TOM Explorer** tree. Use it to write scripts that operate on user-selected objects rather than hardcoded names. + +## Quick reference + +```csharp +// Singular (exactly one selected, throws if 0 or 2+) +Selected.Measure +Selected.Table +Selected.Column + +// Plural (zero or more, safe to iterate) +Selected.Measures +Selected.Tables +Selected.Columns +Selected.Hierarchies +Selected.Partitions +Selected.Levels +Selected.CalculationItems +Selected.Roles +Selected.DataSources + +// Guard clause +if (Selected.Measures.Count() == 0) { Error("Select at least one measure."); return; } + +// Iterate and modify +foreach (var m in Selected.Measures) + m.FormatString = "0.00"; + +// Using ForEach extension +Selected.Measures.ForEach(m => m.DisplayFolder = "KPIs"); +``` + +## Singular vs plural accessors + +The `Selected` object exposes both singular and plural accessors for each object type. + +| Accessor | Returns | Behavior when count is not 1 | +|---|---|---| +| `Selected.Measure` | single `Measure` | Throws exception if 0 or 2+ measures selected | +| `Selected.Measures` | `IEnumerable` | Returns empty collection if none selected | + +Use the **singular** form when your script requires exactly one object. Use the **plural** form when the script should work on one or more objects. + +## Guard clauses + +Always validate the selection before performing operations. This prevents confusing error messages. + +```csharp +// Require at least one measure +if (Selected.Measures.Count() == 0) +{ + Error("No measures selected. Select one or more measures and run again."); + return; +} + +// Require exactly one table +if (Selected.Tables.Count() != 1) +{ + Error("Select exactly one table."); + return; +} +var table = Selected.Table; +``` + +For scripts that accept multiple object types, combine checks: + +```csharp +if (Selected.Columns.Count() == 0 && Selected.Measures.Count() == 0) +{ + Error("Select at least one column or measure."); + return; +} +``` + +## Iterating selected objects + +The plural accessor returns a collection you can iterate with `foreach` or LINQ. + +```csharp +// Set display folder on all selected measures +foreach (var m in Selected.Measures) + m.DisplayFolder = "Sales Metrics"; + +// Hide all selected columns +Selected.Columns.ForEach(c => c.IsHidden = true); + +// Add to a perspective +Selected.Measures.ForEach(m => m.InPerspective["Sales"] = true); +``` + +## Working with the selected table + +When a single table is selected, use `Selected.Table` to add new objects to it. + +```csharp +if (Selected.Tables.Count() != 1) { Error("Select one table."); return; } + +var table = Selected.Table; +var newMeasure = table.AddMeasure( + "Row Count", + "COUNTROWS(" + table.DaxObjectFullName + ")" +); +``` + +## Mixed selections + +When you need to handle multiple object types from the selection, use `Selected.Objects` which returns all selected items as `ITabularNamedObject`. + +```csharp +foreach (var obj in Selected.Objects) +{ + if (obj is IDescriptionObject desc) + desc.Description = "Reviewed on " + DateTime.Today.ToString("yyyy-MM-dd"); +} +``` + +## Try/catch for selection dialogs + +When using `SelectTable()`, `SelectColumn()`, or `SelectMeasure()` helper methods, wrap them in try/catch to handle user cancellation. + +```csharp +try +{ + var table = SelectTable(Model.Tables, null, "Pick a table:"); + Info("You selected: " + table.Name); +} +catch +{ + Error("No table selected."); +} +``` + +> [!NOTE] +> The `Selected` object is only available in interactive contexts (Tabular Editor UI and macros). When running scripts via the CLI with the `-S` flag, `Selected` reflects the objects specified by `-O` arguments or is empty if none are specified. + +## See also + +- @csharp-scripts +- @advanced-scripting +- @how-to-navigate-tom-hierarchy +- @script-helper-methods diff --git a/content/how-tos/scripting-work-with-annotations.md b/content/how-tos/scripting-work-with-annotations.md new file mode 100644 index 00000000..e337e4ff --- /dev/null +++ b/content/how-tos/scripting-work-with-annotations.md @@ -0,0 +1,131 @@ +--- +uid: how-to-work-with-annotations +title: How to Work with Annotations and Extended Properties +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Work with Annotations and Extended Properties + +Annotations and extended properties store custom metadata on TOM objects. Annotations are string key-value pairs. Extended properties support both string and JSON types. Both are persisted in the model and survive deployment. + +## Quick reference + +```csharp +// Annotations +obj.SetAnnotation("key", "value"); // set or create +obj.GetAnnotation("key") // returns string or null +obj.HasAnnotation("key") // returns bool +obj.RemoveAnnotation("key") // delete +obj.GetAnnotations() // IEnumerable of annotation names +obj.ClearAnnotations() // remove all +obj.Annotations // AnnotationCollection (indexer access) + +// Extended properties +obj.SetExtendedProperty("key", "value", ExtendedPropertyType.String); +obj.SetExtendedProperty("key", jsonStr, ExtendedPropertyType.Json); +obj.GetExtendedProperty("key") // returns string +obj.HasExtendedProperty("key") // returns bool +obj.RemoveExtendedProperty("key") // delete +obj.GetExtendedPropertyType("key") // String or Json +obj.ExtendedProperties // ExtendedPropertyCollection (indexer access) +``` + +## Setting and reading annotations + +Any object implementing `IAnnotationObject` supports annotations. This includes tables, columns, measures, hierarchies, partitions, perspectives, roles, data sources and relationships. + +```csharp +// Tag a measure for automation +var m = Model.AllMeasures.First(m => m.Name == "Revenue"); +m.SetAnnotation("AUTOGEN", "true"); +m.SetAnnotation("Owner", "Finance Team"); + +// Read it back +string owner = m.GetAnnotation("Owner"); // "Finance Team" +string missing = m.GetAnnotation("NoKey"); // null +``` + +## Checking and removing annotations + +```csharp +if (m.HasAnnotation("AUTOGEN")) +{ + Info("This measure was auto-generated."); + m.RemoveAnnotation("AUTOGEN"); +} +``` + +## Iterating all annotations on an object + +`GetAnnotations()` returns the annotation names. Use `GetAnnotation(name)` to retrieve values. + +```csharp +foreach (var name in m.GetAnnotations()) +{ + var value = m.GetAnnotation(name); + Info($"{name} = {value}"); +} +``` + +## Using the Annotations collection indexer + +The `Annotations` property provides indexer access as an alternative to the method-based API. + +```csharp +m.Annotations["key"] = "value"; // set +string val = m.Annotations["key"]; // get +``` + +## Bulk annotation operations + +Tag or untag objects across the model. + +```csharp +// Tag all hidden measures +Model.AllMeasures + .Where(m => m.IsHidden) + .ForEach(m => m.SetAnnotation("ReviewStatus", "Hidden")); + +// Remove a specific annotation from all objects that have it +Model.AllMeasures + .Where(m => m.HasAnnotation("OLD_TAG")) + .ForEach(m => m.RemoveAnnotation("OLD_TAG")); +``` + +## Extended properties + +Extended properties work similarly to annotations but support a typed `ExtendedPropertyType` of either `String` or `Json`. + +```csharp +// Store a JSON extended property (e.g., field parameter metadata) +var table = Model.Tables["Parameter"]; +string json = "{\"version\":3,\"values\":[[\"Revenue\"],[\"Cost\"]]}"; +table.SetExtendedProperty("ParameterMetadata", json, ExtendedPropertyType.Json); + +// Read back +string value = table.GetExtendedProperty("ParameterMetadata"); +var type = table.GetExtendedPropertyType("ParameterMetadata"); // ExtendedPropertyType.Json +``` + +## Dynamic LINQ equivalent + +In BPA rule expressions, annotation methods are called directly on the object in context. + +| C# script | Dynamic LINQ (BPA) | +|---|---| +| `m.GetAnnotation("key") == "value"` | `GetAnnotation("key") = "value"` | +| `m.HasAnnotation("key")` | `HasAnnotation("key")` | +| `m.GetAnnotation("key") != null` | `GetAnnotation("key") != null` | +| `m.GetAnnotationsCount() > 0` | `GetAnnotationsCount() > 0` | + +## See also + +- @useful-script-snippets +- @create-field-parameter +- @how-to-navigate-tom-hierarchy diff --git a/content/how-tos/scripting-work-with-dependencies.md b/content/how-tos/scripting-work-with-dependencies.md new file mode 100644 index 00000000..2dd24193 --- /dev/null +++ b/content/how-tos/scripting-work-with-dependencies.md @@ -0,0 +1,156 @@ +--- +uid: how-to-work-with-dependencies +title: How to Work with Dependencies +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Work with Dependencies + +The TOM wrapper tracks which objects reference which other objects through the `DependsOn` and `ReferencedBy` properties. Use these for impact analysis, finding unused objects and understanding DAX lineage. + +## Quick reference + +```csharp +// What does this measure depend on? (direct) +measure.DependsOn.Columns // columns referenced in DAX +measure.DependsOn.Measures // measures referenced in DAX +measure.DependsOn.Tables // tables referenced in DAX +measure.DependsOn.Count // total direct dependency count + +// Transitive (all levels deep) +measure.DependsOn.Deep() // HashSet of all upstream objects + +// Who references this column? (direct) +column.ReferencedBy.Measures // measures that reference this column +column.ReferencedBy.Columns // calculated columns that reference this column +column.ReferencedBy.Tables // calculated tables that reference this column +column.ReferencedBy.Roles // roles (RLS) that reference this column +column.ReferencedBy.Count // total direct reference count + +// Transitive (all levels deep) +column.ReferencedBy.Deep() // HashSet of all downstream +column.ReferencedBy.AllMeasures // all measures downstream (deep) +column.ReferencedBy.AllColumns // all calculated columns downstream (deep) +column.ReferencedBy.AllTables // all calculated tables downstream (deep) +column.ReferencedBy.AnyVisible // true if any downstream object is visible + +// Column-specific structural usage +column.UsedInRelationships // relationships using this column +column.UsedInHierarchies // hierarchies containing this column +column.UsedInSortBy // columns using this as SortByColumn +``` + +## DependsOn: what does this object reference? + +`DependsOn` is available on any `IDaxDependantObject`: measures, calculated columns, calculation items, KPIs, tables and partitions. + +```csharp +var measure = Model.AllMeasures.First(m => m.Name == "Revenue"); + +// List all columns this measure references +foreach (var col in measure.DependsOn.Columns) + Info($"References column: {col.DaxObjectFullName}"); + +// Check if measure depends on a specific table +bool usesDate = measure.DependsOn.Tables.Any(t => t.Name == "Date"); +``` + +## ReferencedBy: what references this object? + +`ReferencedBy` is available on any `IDaxObject`: columns, measures and tables. + +```csharp +var column = Model.Tables["Sales"].Columns["Amount"]; + +// List all measures that reference this column +foreach (var m in column.ReferencedBy.Measures) + Info($"Referenced by: {m.DaxObjectFullName}"); + +// Check if column is used in any RLS expression +bool usedInRLS = column.ReferencedBy.Roles.Any(); +``` + +## Deep traversal + +`Deep()` follows the dependency chain transitively. Use it for full impact analysis. + +```csharp +// All upstream objects (direct + indirect) that a measure depends on +var allUpstream = measure.DependsOn.Deep(); +var upstreamColumns = allUpstream.OfType(); +var upstreamTables = allUpstream.OfType(); + +// All downstream objects that would break if this column is removed +var allDownstream = column.ReferencedBy.Deep(); +var affectedMeasures = allDownstream.OfType(); +``` + +## Finding unused objects + +Objects with no references are candidates for cleanup. + +```csharp +// Measures not referenced by any other DAX expression +var unusedMeasures = Model.AllMeasures + .Where(m => m.ReferencedBy.Count == 0); + +// Hidden columns not referenced by anything (DAX, relationships, hierarchies, sort-by) +var unusedColumns = Model.AllColumns + .Where(c => c.IsHidden + && c.ReferencedBy.Count == 0 + && !c.UsedInRelationships.Any() + && !c.UsedInHierarchies.Any() + && !c.UsedInSortBy.Any()); +``` + +## Impact analysis + +Before renaming or deleting an object, check what depends on it. + +```csharp +var col = Model.Tables["Sales"].Columns["ProductKey"]; + +Info($"Direct references: {col.ReferencedBy.Count}"); +Info($"Relationships: {col.UsedInRelationships.Count()}"); +Info($"Hierarchies: {col.UsedInHierarchies.Count()}"); +Info($"Sort-by: {col.UsedInSortBy.Count()}"); +Info($"Any visible downstream: {col.ReferencedBy.AnyVisible}"); + +// Full downstream tree +var allAffected = col.ReferencedBy.Deep(); +Info($"Total objects affected (deep): {allAffected.Count}"); +``` + +## Dynamic LINQ equivalent + +In BPA rule expressions, dependency properties are accessed directly on the object in context. + +| C# script | Dynamic LINQ (BPA) | +|---|---| +| `m.ReferencedBy.Count == 0` | `ReferencedBy.Count = 0` | +| `m.DependsOn.Any()` | `DependsOn.Any()` | +| `!c.ReferencedBy.AllMeasures.Any(m => !m.IsHidden)` | `not ReferencedBy.AllMeasures.Any(not IsHidden)` | +| `c.UsedInRelationships.Any()` | `UsedInRelationships.Any()` | +| `c.UsedInSortBy.Any()` | `UsedInSortBy.Any()` | +| `c.UsedInHierarchies.Any()` | `UsedInHierarchies.Any()` | +| `c.ReferencedBy.AnyVisible` | `ReferencedBy.AnyVisible` | + +## Common pitfalls + +> [!IMPORTANT] +> - `DependsOn` is only available on `IDaxDependantObject` types: `Measure`, `CalculatedColumn`, `CalculationItem`, `KPI`, `Table`, `Partition`, `TablePermission`. A `DataColumn` does not have `DependsOn` because it has no DAX expression. +> - `ReferencedBy` is only available on `IDaxObject` types: `Column`, `Measure`, `Table`, `Hierarchy`. Not every object type has both properties. +> - `UsedInRelationships`, `UsedInHierarchies` and `UsedInSortBy` are column-specific properties. They track structural usage, not DAX expression references. Check both structural and DAX references to find truly unused columns. +> - `ReferencedBy.Deep()` and `DependsOn.Deep()` can be computationally expensive on large models with deeply nested dependency chains. + +## See also + +- @using-bpa-sample-rules-expressions +- @how-to-filter-query-objects-linq +- @formula-fix-up-dependencies diff --git a/content/how-tos/scripting-work-with-expressions.md b/content/how-tos/scripting-work-with-expressions.md new file mode 100644 index 00000000..54ed0130 --- /dev/null +++ b/content/how-tos/scripting-work-with-expressions.md @@ -0,0 +1,174 @@ +--- +uid: how-to-work-with-expressions +title: How to Work with Expressions and DAX Properties +author: Morten Lønskov +updated: 2026-04-09 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Work with Expressions and DAX Properties + +Measures, calculated columns, calculation items, KPIs and partitions all have expressions. This article covers reading, modifying and generating DAX expressions and working with the `IExpressionObject` interface. + +## Quick reference + +```csharp +// Read and set expressions +measure.Expression // DAX formula string +measure.Expression = "SUM('Sales'[Amount])" // set formula +measure.FormatString = "#,##0.00" // static format +measure.FormatStringExpression = "..." // dynamic format (DAX) + +// Calculated column +calcCol.Expression // DAX formula + +// Partition (M query) +partition.Expression // M/Power Query expression + +// DAX object names for code generation +column.DaxObjectFullName // 'Sales'[Amount] +column.DaxObjectName // [Amount] +measure.DaxObjectFullName // 'Sales'[Revenue] +measure.DaxObjectName // [Revenue] +table.DaxObjectFullName // 'Sales' +table.DaxTableName // 'Sales' + +// Formatting +FormatDax(measure); // queue for formatting +CallDaxFormatter(); // execute queued formatting + +// Tokenizing +measure.Tokenize().Count // DAX token count (complexity metric) +``` + +## Reading and modifying measure expressions + +```csharp +var m = Model.AllMeasures.First(m => m.Name == "Revenue"); + +// Read the current DAX +string dax = m.Expression; + +// Replace a table reference in the expression +m.Expression = m.Expression.Replace("'Old Table'", "'New Table'"); + +// Set format string +m.FormatString = "#,##0.00"; +``` + +## DAX object name properties + +Every `IDaxObject` (table, column, measure, hierarchy) has properties that return its name in DAX-safe format with proper quoting. + +| Property | Column example | Measure example | Table example | +|---|---|---|---| +| `DaxObjectName` | `[Amount]` | `[Revenue]` | `'Sales'` | +| `DaxObjectFullName` | `'Sales'[Amount]` | `[Revenue]` | `'Sales'` | +| `DaxTableName` | `'Sales'` | `'Sales'` | `'Sales'` | + +> [!NOTE] +> For measures, `DaxObjectFullName` returns the same value as `DaxObjectName` (unqualified). Measures do not require table qualification in DAX. For columns, `DaxObjectFullName` includes the table prefix. + +Use these when generating DAX to avoid quoting errors: + +```csharp +// Generate a SUM measure for each selected column +foreach (var col in Selected.Columns) +{ + col.Table.AddMeasure( + "Sum of " + col.Name, + "SUM(" + col.DaxObjectFullName + ")", + col.DisplayFolder + ); +} +``` + +## The IExpressionObject interface + +Objects that hold expressions implement `IExpressionObject`. Cast to the interface for generic code that works across measures, partitions, calculation items, etc. + +```csharp +// List all expression types on an object +var exprObj = (IExpressionObject)measure; +foreach (var prop in exprObj.GetExpressionProperties()) +{ + string expr = exprObj.GetExpression(prop); + if (!string.IsNullOrEmpty(expr)) + Info($"{prop}: {expr}"); +} + +// Set an expression by type +exprObj.SetExpression(ExpressionProperty.Expression, "SUM('Sales'[Amount])"); +exprObj.SetExpression(ExpressionProperty.FormatStringExpression, "\"$#,##0.00\""); +``` + +The `ExpressionProperty` enum includes: + +| Value | Used on | +|---|---| +| `Expression` | Measures, calculated columns, calculation items | +| `DetailRowsExpression` | Measures | +| `FormatStringExpression` | Measures, calculation items | +| `TargetExpression` | KPIs | +| `StatusExpression` | KPIs | +| `TrendExpression` | KPIs | +| `MExpression` | M partitions | + +## Formatting DAX + +Use `FormatDax()` to queue objects for formatting and `CallDaxFormatter()` to execute. + +```csharp +// Format all measures in the model +foreach (var m in Model.AllMeasures) + FormatDax(m); +CallDaxFormatter(); +``` + +## Tokenizing for complexity analysis + +`Tokenize()` returns the DAX tokens in an expression. Use it to measure expression complexity. + +```csharp +foreach (var m in Model.AllMeasures.OrderByDescending(m => m.Tokenize().Count)) + Info($"{m.Name}: {m.Tokenize().Count} tokens"); +``` + +## Find and replace in expressions + +```csharp +// Replace a column reference across all measures +foreach (var m in Model.AllMeasures.Where(m => m.Expression.Contains("[Old Column]"))) +{ + m.Expression = m.Expression.Replace("[Old Column]", "[New Column]"); +} +``` + +## Dynamic LINQ equivalent + +In BPA rule expressions, expression properties are accessed directly on the object in context. + +| C# script | Dynamic LINQ (BPA) | +|---|---| +| `string.IsNullOrWhiteSpace(m.Expression)` | `String.IsNullOrWhitespace(Expression)` | +| `m.Expression.Contains("CALCULATE")` | `Expression.Contains("CALCULATE")` | +| `m.FormatString == ""` | `FormatString = ""` | +| `m.Expression.StartsWith("SUM")` | `Expression.StartsWith("SUM")` | + +## Common pitfalls + +> [!IMPORTANT] +> - `DataColumn` does not have an `Expression` property. Only `CalculatedColumn`, `Measure`, `CalculationItem` and `Partition` have expressions. Accessing `Expression` on a `DataColumn` causes a compile error or runtime exception depending on context. +> - `DaxObjectName` returns the unqualified name (e.g., `[Revenue]`) while `DaxObjectFullName` includes the table prefix (e.g., `'Sales'[Revenue]`). Use `DaxObjectFullName` for column references in DAX and `DaxObjectName` for measure references where table qualification is optional. +> - `FormatDax()` only queues the object. You must call `CallDaxFormatter()` to actually format the expressions. The formatter requires an internet connection (it calls the daxformatter.com API). + +## See also + +- @csharp-scripts +- @using-bpa-sample-rules-expressions +- @how-to-filter-query-objects-linq +- @script-find-replace diff --git a/content/how-tos/toc.md b/content/how-tos/toc.md index 28fafa4e..230624e4 100644 --- a/content/how-tos/toc.md +++ b/content/how-tos/toc.md @@ -2,6 +2,19 @@ ## [Advanced Scripting](Advanced-Scripting.md) ## [Script Reference Objects](script-reference-objects.md) +# Scripting Patterns +## [Navigate the TOM Object Hierarchy](scripting-navigate-tom-hierarchy.md) +## [Check Object Types](scripting-check-object-types.md) +## [Use the Selected Object](scripting-use-selected-object.md) +## [Filter and Query Objects with LINQ](scripting-filter-query-linq.md) +## [Work with Dependencies](scripting-work-with-dependencies.md) +## [Work with Annotations and Extended Properties](scripting-work-with-annotations.md) +## [Work with Perspectives and Translations](scripting-perspectives-translations.md) +## [Work with Expressions and DAX Properties](scripting-work-with-expressions.md) +## [Add, Clone and Remove Objects](scripting-add-clone-remove-objects.md) +## [Use Script UI Helpers](scripting-ui-helpers.md) +## [Dynamic LINQ vs C# LINQ](scripting-dynamic-linq-vs-csharp.md) + # Model Management and Deployment ## [Deploy Current Model](deploy-current-model.md) ## [Connect to SSAS](connect-ssas.md) From d80c8d13c7de89926a4bc7a0963688b17e9a03bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20L=C3=B8nskov?= Date: Fri, 10 Apr 2026 11:04:56 +0200 Subject: [PATCH 2/5] Update with TE2 specific adaptations --- content/how-tos/scripting-ui-helpers.md | 7 +++++-- content/how-tos/scripting-use-selected-object.md | 4 ++-- .../how-tos/scripting-work-with-expressions.md | 15 ++++++++++++--- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/content/how-tos/scripting-ui-helpers.md b/content/how-tos/scripting-ui-helpers.md index 5356f995..6f32950e 100644 --- a/content/how-tos/scripting-ui-helpers.md +++ b/content/how-tos/scripting-ui-helpers.md @@ -33,7 +33,7 @@ var table = SelectTable(); // pick a table var column = SelectColumn(table.Columns); // pick from filtered columns var measure = SelectMeasure(); // pick a measure var obj = SelectObject(Model.DataSources); // generic selection -var items = SelectObjects(collection); // multi-select +var items = SelectObjects(collection); // multi-select (TE3 only) // Evaluate DAX var result = EvaluateDax("COUNTROWS('Sales')"); // run DAX on connected model @@ -107,7 +107,10 @@ catch } ``` -### Multi-select +### Multi-select (Tabular Editor 3 only) + +> [!NOTE] +> `SelectObjects()` is only available in Tabular Editor 3. In Tabular Editor 2, use a single-select dialog in a loop or filter the selection before running the script. `SelectObjects()` allows the user to pick multiple objects. diff --git a/content/how-tos/scripting-use-selected-object.md b/content/how-tos/scripting-use-selected-object.md index 5f8cacc3..f8f87b63 100644 --- a/content/how-tos/scripting-use-selected-object.md +++ b/content/how-tos/scripting-use-selected-object.md @@ -118,10 +118,10 @@ var newMeasure = table.AddMeasure( ## Mixed selections -When you need to handle multiple object types from the selection, use `Selected.Objects` which returns all selected items as `ITabularNamedObject`. +When you need to handle multiple object types from the selection, iterate `Selected` directly. The `Selected` variable itself implements `IEnumerable`. ```csharp -foreach (var obj in Selected.Objects) +foreach (var obj in Selected) { if (obj is IDescriptionObject desc) desc.Description = "Reviewed on " + DateTime.Today.ToString("yyyy-MM-dd"); diff --git a/content/how-tos/scripting-work-with-expressions.md b/content/how-tos/scripting-work-with-expressions.md index 54ed0130..812d1aaf 100644 --- a/content/how-tos/scripting-work-with-expressions.md +++ b/content/how-tos/scripting-work-with-expressions.md @@ -89,10 +89,19 @@ foreach (var col in Selected.Columns) ## The IExpressionObject interface -Objects that hold expressions implement `IExpressionObject`. Cast to the interface for generic code that works across measures, partitions, calculation items, etc. +Objects that hold expressions implement `IExpressionObject`. In Tabular Editor 2, this interface provides only the `Expression` property. In Tabular Editor 3, it adds `GetExpression()`, `SetExpression()` and `GetExpressionProperties()` for working with multiple expression types on a single object. ```csharp -// List all expression types on an object +// Tabular Editor 2: use the Expression property directly +measure.Expression = "SUM('Sales'[Amount])"; +string dax = measure.Expression; +``` + +> [!NOTE] +> The following `GetExpression`/`SetExpression` pattern is only available in Tabular Editor 3. In Tabular Editor 2, access the `Expression` property directly on the object. + +```csharp +// Tabular Editor 3 only: list all expression types on an object var exprObj = (IExpressionObject)measure; foreach (var prop in exprObj.GetExpressionProperties()) { @@ -106,7 +115,7 @@ exprObj.SetExpression(ExpressionProperty.Expression, "SUM('Sales'[Amount])"); exprObj.SetExpression(ExpressionProperty.FormatStringExpression, "\"$#,##0.00\""); ``` -The `ExpressionProperty` enum includes: +The `ExpressionProperty` enum (Tabular Editor 3 only) includes: | Value | Used on | |---|---| From 5e301fe9a878a4a484d401f0cd9156a4b406bf2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20L=C3=B8nskov?= Date: Fri, 10 Apr 2026 23:36:17 +0200 Subject: [PATCH 3/5] updates based on gregs comments --- content/features/Useful-script-snippets.md | 3 +- .../scripting-add-clone-remove-objects.md | 96 +++++++++++----- .../how-tos/scripting-check-object-types.md | 99 ++++++---------- .../scripting-dynamic-linq-vs-csharp.md | 54 ++++++--- .../how-tos/scripting-filter-query-linq.md | 67 +++++------ .../scripting-navigate-tom-hierarchy.md | 23 ++-- .../scripting-perspectives-translations.md | 2 +- content/how-tos/scripting-tom-interfaces.md | 108 ++++++++++++++++++ content/how-tos/scripting-ui-helpers.md | 11 +- .../how-tos/scripting-use-selected-object.md | 49 ++++---- .../scripting-work-with-annotations.md | 32 ++++-- .../scripting-work-with-dependencies.md | 15 ++- .../scripting-work-with-expressions.md | 23 ++-- content/how-tos/toc.md | 7 +- 14 files changed, 376 insertions(+), 213 deletions(-) create mode 100644 content/how-tos/scripting-tom-interfaces.md diff --git a/content/features/Useful-script-snippets.md b/content/features/Useful-script-snippets.md index 944b4db0..02616506 100644 --- a/content/features/Useful-script-snippets.md +++ b/content/features/Useful-script-snippets.md @@ -2,6 +2,7 @@ uid: useful-script-snippets title: Useful script snippets author: Daniel Otykier +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -22,7 +23,7 @@ Here's a collection of small script snippets to get you started using the [Advan Also, make sure to check out our script library @csharp-script-library, for some more real-life examples of what you can do with the scripting capabilities of Tabular Editor. > [!TIP] -> For structured, pattern-by-pattern reference material on C# scripting and Dynamic LINQ, see the [Scripting Patterns](/how-tos/scripting-navigate-tom-hierarchy) how-to series. It covers object navigation, type checking, LINQ querying, dependencies, annotations, expressions and more. +> For structured, pattern-by-pattern reference material on C# scripting and Dynamic LINQ, see the [Scripting Patterns](/how-tos/scripting-navigate-tom-hierarchy) how-to series. For the complete TOM wrapper API, see the @api-index. *** diff --git a/content/how-tos/scripting-add-clone-remove-objects.md b/content/how-tos/scripting-add-clone-remove-objects.md index 32f17898..226990b7 100644 --- a/content/how-tos/scripting-add-clone-remove-objects.md +++ b/content/how-tos/scripting-add-clone-remove-objects.md @@ -2,7 +2,7 @@ uid: how-to-add-clone-remove-objects title: How to Add, Clone and Remove Objects author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -17,7 +17,8 @@ C# scripts can create new model objects, clone existing ones and delete objects. ## Quick reference ```csharp -// Add objects +// Add objects -- all parameters after the first are optional. +// See sections below for parameter details. table.AddMeasure("Name", "DAX Expression", "Display Folder"); table.AddCalculatedColumn("Name", "DAX Expression", "Display Folder"); table.AddDataColumn("Name", "SourceColumn", "Display Folder", DataType.String); @@ -29,58 +30,78 @@ Model.AddTranslation("da-DK"); // Relationships var rel = Model.AddRelationship(); -rel.FromColumn = Model.Tables["Sales"].Columns["ProductKey"]; -rel.ToColumn = Model.Tables["Product"].Columns["ProductKey"]; +rel.FromColumn = Model.Tables["Sales"].Columns["ProductKey"]; // many (N) side +rel.ToColumn = Model.Tables["Product"].Columns["ProductKey"]; // one (1) side // Clone var clone = measure.Clone("New Name"); // same table var clone = measure.Clone("New Name", true, targetTable); // different table -// Delete (always materialize first when deleting in a loop) +// Delete (always materialize with ToList() before modifying a collection in a loop) measure.Delete(); table.Measures.Where(m => m.IsHidden).ToList().ForEach(m => m.Delete()); ``` ## Adding measures -`AddMeasure()` creates a new measure on a table. All parameters except the first are optional. +`AddMeasure()` creates and returns a new `Measure` on a table. The first parameter is the name, the second is a DAX expression and the third is the display folder. All parameters except the first are optional. + +Capture the returned object in a variable to set additional properties. This pattern is the same across all `Add*` methods. ```csharp var table = Model.Tables["Sales"]; -// Simple measure -var m = table.AddMeasure("Revenue", "SUM('Sales'[Amount])"); +// Create a measure and set properties on the returned object +var m = table.AddMeasure( + "Revenue", // name + "SUM('Sales'[Amount])" // DAX expression +); m.FormatString = "#,##0.00"; m.Description = "Total sales amount"; // With display folder -var m2 = table.AddMeasure("Cost", "SUM('Sales'[Cost])", "Financial"); +var m2 = table.AddMeasure( + "Cost", // name + "SUM('Sales'[Cost])", // DAX expression + "Financial" // display folder +); ``` ## Adding columns ```csharp -// Calculated column (DAX expression) -var cc = table.AddCalculatedColumn("Profit", "'Sales'[Amount] - 'Sales'[Cost]"); +// Calculated column -- first parameter is the name, second is a DAX expression +var cc = table.AddCalculatedColumn( + "Profit", // name + "'Sales'[Amount] - 'Sales'[Cost]" // DAX expression +); cc.DataType = DataType.Decimal; cc.FormatString = "#,##0.00"; -// Data column (maps to a source column) -var dc = table.AddDataColumn("Region", "RegionName", "Geography", DataType.String); +// Data column -- maps to a source column in the partition query +var dc = table.AddDataColumn( + "Region", // name + "RegionName", // source column name + "Geography", // display folder + DataType.String // data type +); ``` +> [!WARNING] +> Adding a data column does not modify the table's partition query. You must update the M expression or SQL query separately to include a source column that matches the `sourceColumn` parameter. + ## Adding hierarchies -Pass columns as parameters to automatically create levels. +The `levels` parameter is variadic. Pass any number of columns in a single call to create the corresponding levels automatically. ```csharp var dateTable = Model.Tables["Date"]; var h = dateTable.AddHierarchy( - "Calendar", - "", - dateTable.Columns["Year"], - dateTable.Columns["Quarter"], - dateTable.Columns["Month"] + "Calendar", // name + "", // display folder + dateTable.Columns["Year"], // level 1 + dateTable.Columns["Quarter"], // level 2 + dateTable.Columns["Month"] // level 3 ); ``` @@ -96,19 +117,28 @@ h.AddLevel(dateTable.Columns["FiscalMonth"]); ## Adding calculated tables ```csharp -var ct = Model.AddCalculatedTable("DateKey List", "VALUES('Date'[DateKey])"); +var ct = Model.AddCalculatedTable( + "DateKey List", // name + "VALUES('Date'[DateKey])" // DAX expression +); ``` ## Adding relationships -`AddRelationship()` creates an empty relationship. You must set the columns explicitly. +`AddRelationship()` creates and returns an empty relationship. You must set the columns explicitly. + +`FromColumn` is the many (N) side and `ToColumn` is the one (1) side. Tabular Editor does not detect the direction automatically. A useful mnemonic: F for From, F for Fact table (the many side). + +New relationships default to `CrossFilteringBehavior.OneDirection` and `IsActive = true`. Set these only if you need a different value. ```csharp var rel = Model.AddRelationship(); -rel.FromColumn = Model.Tables["Sales"].Columns["ProductKey"]; -rel.ToColumn = Model.Tables["Product"].Columns["ProductKey"]; -rel.CrossFilteringBehavior = CrossFilteringBehavior.OneDirection; -rel.IsActive = true; +rel.FromColumn = Model.Tables["Sales"].Columns["ProductKey"]; // many side (fact) +rel.ToColumn = Model.Tables["Product"].Columns["ProductKey"]; // one side (dimension) + +// Only set these if you need non-default values: +// rel.CrossFilteringBehavior = CrossFilteringBehavior.BothDirections; +// rel.IsActive = false; ``` ## Cloning objects @@ -126,7 +156,7 @@ var copy2 = original.Clone("Revenue Copy", true, Model.Tables["Reporting"]); ## Generating measures from columns -A common pattern: iterate selected columns and create derived measures. +A common pattern: iterate selected columns and create derived measures. Note the use of `DaxObjectFullName` which returns the fully qualified, properly quoted DAX reference (e.g., `'Sales'[Amount]`) to avoid quoting errors. ```csharp foreach (var col in Selected.Columns) @@ -143,7 +173,7 @@ foreach (var col in Selected.Columns) ## Deleting objects -Call `Delete()` on any named object to remove it. When deleting inside a loop, always call `.ToList()` first to avoid modifying the collection during iteration. +Call `Delete()` on any named object to remove it. When modifying a collection in a loop (deleting, adding or moving objects), always call `.ToList()` first to materialize a snapshot. ```csharp // Delete a single object @@ -159,10 +189,10 @@ Model.AllMeasures ## Common pitfalls > [!WARNING] -> - Always call `.ToList()` before deleting objects in a loop. Without it, modifying the collection during iteration causes an exception. -> - `AddRelationship()` creates an incomplete relationship. You must assign both `FromColumn` and `ToColumn` before the model validates. Failing to do so results in a validation error. -> - New objects have default property values. Set `DataType`, `FormatString`, `IsHidden` and other properties explicitly after creation. -> - `Clone()` copies all metadata including annotations, translations and perspective membership. If you do not want to inherit these, remove them after cloning. +> - Always call `.ToList()` or `.ToArray()` before modifying objects in a loop. Without it, modifying the collection during iteration causes: `"Collection was modified; enumeration operation may not complete."` +> - `AddRelationship()` creates an incomplete relationship. You must assign both `FromColumn` and `ToColumn` before the model validates. +> - `Column` is abstract, but you can access all base properties (`Name`, `DataType`, `FormatString`, `IsHidden`) without casting. Only cast to a subtype for type-specific properties. +> - `Clone()` copies all metadata including annotations, translations and perspective membership. Remove unwanted metadata after cloning. ## See also @@ -170,3 +200,7 @@ Model.AllMeasures - @script-create-sum-measures-from-columns - @how-to-navigate-tom-hierarchy - @how-to-use-selected-object +- (xref:TabularEditor.TOMWrapper.Measure) -- Measure API reference +- (xref:TabularEditor.TOMWrapper.Column) -- Column API reference +- (xref:TabularEditor.TOMWrapper.Hierarchy) -- Hierarchy API reference +- (xref:TabularEditor.TOMWrapper.SingleColumnRelationship) -- Relationship API reference diff --git a/content/how-tos/scripting-check-object-types.md b/content/how-tos/scripting-check-object-types.md index 91f9c53c..c598d5c1 100644 --- a/content/how-tos/scripting-check-object-types.md +++ b/content/how-tos/scripting-check-object-types.md @@ -2,7 +2,7 @@ uid: how-to-check-object-types title: How to Check Object Types author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -12,44 +12,43 @@ applies_to: --- # How to Check Object Types -The TOM hierarchy uses inheritance. `Column` is an abstract base with subtypes `DataColumn`, `CalculatedColumn` and `CalculatedTableColumn`. `Table` has the subtype `CalculationGroupTable`. This article shows how to test and filter by type in C# scripts and Dynamic LINQ. +The TOM hierarchy uses inheritance. `Column` is an abstract base with subtypes `DataColumn`, `CalculatedColumn` and `CalculatedTableColumn`. `Table` has subtypes `CalculatedTable` and `CalculationGroupTable`. Use the base type when working with shared properties like `Name`, `Description`, `IsHidden`, `FormatString` or `DisplayFolder`. Cast to a concrete subtype when you need type-specific properties, such as `Expression` on `CalculatedColumn` or `SourceColumn` on `DataColumn`. ## Quick reference ```csharp -// Pattern matching (preferred) +// Pattern matching -- checks type AND casts in one step if (col is CalculatedColumn cc) - Info(cc.Expression); + Info(cc.Expression); // Expression is only on CalculatedColumn, not base Column // Filter a collection by type var calcCols = Model.AllColumns.OfType(); var calcGroups = Model.Tables.OfType(); -// Runtime type name +// Runtime type name (use only for display/logging, not for logic) string typeName = obj.GetType().Name; // "DataColumn", "Measure", etc. - -// Null-safe cast -var dc = col as DataColumn; -if (dc != null) { /* use dc */ } ``` +> [!NOTE] +> Pattern matching with variable declaration (`col is CalculatedColumn cc`) requires the Roslyn compiler in Tabular Editor 2. Enable it under **File > Preferences > General > Use Roslyn compiler**. Tabular Editor 3 supports this by default. + ## Type hierarchy The key inheritance relationships in the TOM wrapper: | Base type | Subtypes | |---|---| -| `Column` | `DataColumn`, `CalculatedColumn`, `CalculatedTableColumn` | -| `Table` | `CalculatedTable`, `CalculationGroupTable` | -| `Partition` | `MPartition`, `EntityPartition`, `PolicyRangePartition` | -| `DataSource` | `ProviderDataSource`, `StructuredDataSource` | +| (xref:TabularEditor.TOMWrapper.Column) | (xref:TabularEditor.TOMWrapper.DataColumn), (xref:TabularEditor.TOMWrapper.CalculatedColumn), (xref:TabularEditor.TOMWrapper.CalculatedTableColumn) | +| (xref:TabularEditor.TOMWrapper.Table) | (xref:TabularEditor.TOMWrapper.CalculatedTable), (xref:TabularEditor.TOMWrapper.CalculationGroupTable) | +| (xref:TabularEditor.TOMWrapper.Partition) | (xref:TabularEditor.TOMWrapper.MPartition), (xref:TabularEditor.TOMWrapper.EntityPartition), (xref:TabularEditor.TOMWrapper.PolicyRangePartition) | +| (xref:TabularEditor.TOMWrapper.DataSource) | (xref:TabularEditor.TOMWrapper.ProviderDataSource), (xref:TabularEditor.TOMWrapper.StructuredDataSource) | ## Filtering collections by type -`OfType()` filters and casts in one step. Prefer it over `Where(x => x is T)`. +`OfType()` works on any collection and returns a filtered sequence containing only items that are the specified type. It returns an empty sequence if no items match. ```csharp -// All calculated columns in the model +// All calculated columns in the model (empty if model has none) var calculatedColumns = Model.AllColumns.OfType(); // All M partitions (Power Query) @@ -58,85 +57,51 @@ var mPartitions = Model.AllPartitions.OfType(); // All calculation group tables var calcGroups = Model.Tables.OfType(); -// All regular tables (exclude calculation groups) -var regularTables = Model.Tables.Where(t => t is not CalculationGroupTable); +// All regular tables (exclude calculation groups and calculated tables) +var regularTables = Model.Tables.Where(t => t is not CalculationGroupTable && t is not CalculatedTable); ``` ## Pattern matching with is -Use C# pattern matching to test and cast in a single expression. - -```csharp -foreach (var col in Model.AllColumns) -{ - if (col is CalculatedColumn cc) - Info($"{cc.Name}: {cc.Expression}"); - else if (col is DataColumn dc) - Info($"{dc.Name}: data column in {dc.Table.Name}"); -} -``` - -## Checking ObjectType enum +Pattern matching does two things: it checks whether a value is a given type and optionally casts it into a new variable. The form `x is Type xx` asks "is `x` of type `Type`?" and, if true, gives you `xx` as a variable of that exact type. -Every TOM object has an `ObjectType` property that returns an enum value. This identifies the base type, not the subtype. +This is equivalent to: ```csharp -foreach (var obj in Selected.Objects) +if (col is CalculatedColumn) { - switch (obj.ObjectType) - { - case ObjectType.Measure: /* ... */ break; - case ObjectType.Column: /* ... */ break; - case ObjectType.Table: /* ... */ break; - case ObjectType.Hierarchy: /* ... */ break; - } + var cc = (CalculatedColumn)col; // explicit cast + // use cc... } ``` -> [!WARNING] -> `ObjectType` does not distinguish subtypes. A `CalculatedColumn` and a `DataColumn` both return `ObjectType.Column`. Use `is` or `OfType()` when you need subtype-level checks. - -## Checking partition source type - -For partitions, use the `SourceType` property to distinguish between storage modes without type-casting. +If you only need the boolean check, use `x is Type` without the variable. If you also need subtype-specific properties, use `x is Type xx`. ```csharp -foreach (var p in Model.AllPartitions) +foreach (var col in Model.AllColumns) { - switch (p.SourceType) - { - case PartitionSourceType.M: /* Power Query */ break; - case PartitionSourceType.Calculated: /* DAX calc table */ break; - case PartitionSourceType.Entity: /* Direct Lake */ break; - case PartitionSourceType.Query: /* Legacy SQL */ break; - } + // Expression is only available on CalculatedColumn, not the base Column type + if (col is CalculatedColumn cc) + Info($"{cc.Name}: {cc.Expression}"); + else if (col is DataColumn dc) + Info($"{dc.Name}: data column in {dc.Table.Name}"); } ``` ## Dynamic LINQ equivalent -In BPA rules, type filtering works differently. Set the rule's **Applies to** scope to target a specific object type. Within the expression, use `ObjectTypeName` for the base type name as a string. - -``` -// BPA expression context: the rule "Applies to" determines the object type -// No C#-style type casting is available in Dynamic LINQ - -// Check the object type name (rarely needed since scope handles this) -ObjectTypeName = "Measure" -ObjectTypeName = "Column" -``` - -For subtypes like calculated columns, set the BPA rule scope to **Calculated Columns** rather than trying to filter by type in the expression. +In BPA rules, type filtering is handled by the rule's **Applies to** scope. Set it to the target object type (e.g., **Calculated Columns**) rather than filtering by type in the expression. No C#-style type casting is available in Dynamic LINQ. ## Common pitfalls > [!IMPORTANT] -> - `Column` is abstract. You cannot create an instance of `Column` directly. Use `table.AddDataColumn()` or `table.AddCalculatedColumn()` on regular tables. `AddCalculatedTableColumn()` is only available on `CalculatedTable`. +> - `Column` is abstract, but you can access all properties defined on the base type (`Name`, `DataType`, `FormatString`, `IsHidden`, `Description`, `DisplayFolder`) without casting. Only cast to a subtype when you need subtype-specific properties like `Expression` on `CalculatedColumn`. > - `OfType()` both filters and casts. `Where(x => x is T)` only filters, leaving you with the base type. Prefer `OfType()` when you need access to subtype properties. -> - `ObjectType` and `SourceType` are base-level checks. For subtype-specific logic (e.g., accessing `Expression` on a `CalculatedColumn`), use `is` or `OfType()`. +> - Calculated table columns are managed automatically. Edit the calculated table's `Expression` to add or change columns. You cannot add them directly. ## See also - @csharp-scripts - @using-bpa-sample-rules-expressions - @how-to-navigate-tom-hierarchy +- @how-to-tom-interfaces diff --git a/content/how-tos/scripting-dynamic-linq-vs-csharp.md b/content/how-tos/scripting-dynamic-linq-vs-csharp.md index 4e139015..93141103 100644 --- a/content/how-tos/scripting-dynamic-linq-vs-csharp.md +++ b/content/how-tos/scripting-dynamic-linq-vs-csharp.md @@ -2,7 +2,7 @@ uid: how-to-dynamic-linq-vs-csharp-linq title: How Dynamic LINQ Differs from C# LINQ author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -25,6 +25,8 @@ C# scripts use standard C# LINQ with lambda expressions. Best Practice Analyzer ## Syntax comparison +In Dynamic LINQ, the object is implicit -- there is no lambda parameter like `m.` or `c.`. In BPA, the surrounding context is the **Applies to** scope setting, which determines which object type the expression evaluates against. + | Concept | C# LINQ (scripts) | Dynamic LINQ (BPA / filter) | |---|---|---| | Boolean AND | `&&` | `and` | @@ -57,8 +59,10 @@ C# LINQ uses explicit lambda parameters. Dynamic LINQ evaluates properties on an ```csharp // C# LINQ: explicit lambda parameter -Model.AllMeasures.Where(m => m.IsHidden && m.Description == "") +Model.AllMeasures.Where(m => m.IsHidden && m.Description == ""); +``` +``` // Dynamic LINQ: implicit "it" -- properties are accessed directly IsHidden and Description = "" ``` @@ -69,8 +73,10 @@ Both use dot notation, but C# requires the lambda parameter. ```csharp // C# LINQ -Model.AllMeasures.Where(m => m.Table.IsHidden) +Model.AllMeasures.Where(m => m.Table.IsHidden); +``` +``` // Dynamic LINQ Table.IsHidden ``` @@ -81,8 +87,10 @@ C# LINQ uses lambdas inside collection methods. Dynamic LINQ uses implicit conte ```csharp // C# LINQ: count columns with no description -Model.Tables.Where(t => t.Columns.Count(c => c.Description == "") > 5) +Model.Tables.Where(t => t.Columns.Count(c => c.Description == "") > 5); +``` +``` // Dynamic LINQ: same logic Columns.Count(Description = "") > 5 ``` @@ -96,22 +104,21 @@ Inside a nested collection method in Dynamic LINQ, `it` refers to the inner obje Columns.Any(Name = outerIt.Name) ``` -In C#, this is handled naturally by lambda closure: +In C#, the outer lambda parameter `t` remains in scope throughout the inner lambda body. The inner lambda `c => c.Name == t.Name` can reference `t` directly because it is captured by closure. ```csharp -// C# equivalent -Model.Tables.Where(t => t.Columns.Any(c => c.Name == t.Name)) +// C# equivalent -- t is accessible inside the inner lambda via closure +Model.Tables.Where(t => t.Columns.Any(c => c.Name == t.Name)); ``` ## Type filtering -C# uses `OfType()` or `is`. Dynamic LINQ relies on the BPA rule's **Applies to** scope setting. +C# uses `OfType()` or `is`. In BPA, the rule's **Applies to** scope handles type filtering. You do not need type checks in the expression itself. | C# LINQ | Dynamic LINQ approach | |---|---| | `Model.AllColumns.OfType()` | Set BPA rule scope to **Calculated Columns** | | `Model.Tables.OfType()` | Set BPA rule scope to **Calculation Group Tables** | -| `obj is Measure` | Rule scope handles this; use `ObjectTypeName = "Measure"` if needed | ## Dependency properties @@ -128,8 +135,10 @@ These work identically in both syntaxes, but Dynamic LINQ omits the object prefi ```csharp // C# LINQ -Model.AllMeasures.Where(m => m.HasAnnotation("AUTOGEN")) +Model.AllMeasures.Where(m => m.HasAnnotation("AUTOGEN")); +``` +``` // Dynamic LINQ HasAnnotation("AUTOGEN") ``` @@ -143,8 +152,10 @@ HasAnnotation("AUTOGEN") ```csharp // C# LINQ -Model.AllMeasures.Where(m => m.InPerspective["Sales"]) +Model.AllMeasures.Where(m => m.InPerspective["Sales"]); +``` +``` // Dynamic LINQ InPerspective["Sales"] ``` @@ -157,17 +168,26 @@ InPerspective["Sales"] ## BPA fix expressions -Fix expressions use `it.` as the assignment target. The left side of the assignment refers to the object that violated the rule. +Fix expressions use `it.` as the assignment target. The `it` refers to the specific object that violated the rule -- the same object highlighted in the BPA results list. -``` -// Set IsHidden to true on the violating object -it.IsHidden = true +For example, given a BPA rule with expression `IsHidden and String.IsNullOrWhitespace(Description)` applied to **Measures**, each measure that matches appears in the BPA results. When you apply the fix, `it` refers to that specific measure: -// Set description +``` +// Set the description on the violating measure it.Description = "TODO: Add description" + +// Unhide the violating object +it.IsHidden = false ``` -There is no C# LINQ equivalent -- fix expressions are a BPA-specific feature. +While fix expressions have no direct C# LINQ equivalent, you can achieve the same result in a script: + +```csharp +foreach (var m in Model.AllMeasures.Where(m => m.IsHidden && string.IsNullOrWhiteSpace(m.Description))) +{ + m.Description = "TODO: Add description"; +} +``` ## Complete example: same rule in both syntaxes diff --git a/content/how-tos/scripting-filter-query-linq.md b/content/how-tos/scripting-filter-query-linq.md index f7f32b86..62f3e695 100644 --- a/content/how-tos/scripting-filter-query-linq.md +++ b/content/how-tos/scripting-filter-query-linq.md @@ -2,7 +2,7 @@ uid: how-to-filter-query-objects-linq title: How to Filter and Query Objects with LINQ author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -12,40 +12,39 @@ applies_to: --- # How to Filter and Query Objects with LINQ -C# scripts use standard LINQ methods to filter, search and transform TOM object collections. This article covers the essential LINQ patterns for querying semantic model objects. +C# scripts use standard LINQ methods to filter, search and transform TOM object collections. These patterns are building blocks. Use collection-returning methods in `foreach` loops, bool-returning methods in `if` conditions and scalar-returning methods in variable assignments. ## Quick reference ```csharp -// Filter -Model.AllMeasures.Where(m => m.Expression.Contains("CALCULATE")) +// Filter -- returns a collection for use in foreach or further chaining +Model.AllMeasures.Where(m => m.Name.EndsWith("Amount")); -// Find one -Model.Tables.First(t => t.Name == "Sales") -Model.Tables.FirstOrDefault(t => t.Name == "Sales") // returns null if not found +// Find one -- returns a single object for assignment to a variable +var table = Model.Tables.First(t => t.Name == "Sales"); +var tableOrNull = Model.Tables.FirstOrDefault(t => t.Name == "Sales"); -// Existence checks -table.Measures.Any(m => m.IsHidden) // true if at least one -table.Columns.All(c => c.Description != "") // true if every one +// Existence checks -- returns bool for use in if conditions +if (table.Measures.Any(m => m.IsHidden)) { /* ... */ } +if (table.Columns.All(c => c.Description != "")) { /* ... */ } // Count -Model.AllColumns.Count(c => c.DataType == DataType.String) +int count = Model.AllColumns.Count(c => c.DataType == DataType.String); -// Project -Model.AllMeasures.Select(m => m.Name).ToList() +// Project -- returns a List of only the measure names +var names = Model.AllMeasures.Select(m => m.Name).ToList(); // Sort -Model.AllMeasures.OrderBy(m => m.Name) -Model.AllMeasures.OrderByDescending(m => m.Table.Name) +var sorted = Model.AllMeasures.OrderBy(m => m.Name); // Mutate -Model.AllMeasures.Where(m => m.FormatString == "").ForEach(m => m.FormatString = "0.00") +Model.AllMeasures.Where(m => m.FormatString == "").ForEach(m => m.FormatString = "0.00"); // Type filter -Model.AllColumns.OfType() +var calcCols = Model.AllColumns.OfType(); -// Materialize before deleting -table.Measures.Where(m => m.IsHidden).ToList().ForEach(m => m.Delete()) +// Materialize before modifying the collection +table.Measures.Where(m => m.IsHidden).ToList().ForEach(m => m.Delete()); ``` ## Filtering with Where @@ -53,15 +52,17 @@ table.Measures.Where(m => m.IsHidden).ToList().ForEach(m => m.Delete()) `Where()` returns all objects matching a predicate. Chain multiple conditions with `&&` and `||`. ```csharp -// Measures that use CALCULATE and are hidden -var matches = Model.AllMeasures - .Where(m => m.Expression.Contains("CALCULATE") && m.IsHidden); - // Columns with no description in a specific table var undocumented = Model.Tables["Sales"].Columns .Where(c => string.IsNullOrEmpty(c.Description)); ``` +> [!WARNING] +> String matching with `Contains()` finds the text anywhere in the expression, including inside string literals and comments. To detect actual DAX function usage, analyze the tokenized expression instead. + +> [!TIP] +> When checking expression content with `Contains()`, consider case-insensitive comparison: `m.Expression.Contains("calculate", StringComparison.OrdinalIgnoreCase)`. + ## Finding a single object `First()` returns the first match or throws if none exist. `FirstOrDefault()` returns null instead of throwing. @@ -72,15 +73,12 @@ var sales = Model.Tables.First(t => t.Name == "Sales"); // Returns null if not found (safe) var table = Model.Tables.FirstOrDefault(t => t.Name == "Sales"); -if (table == null) { Error("Table not found."); return; } +if (table == null) { Error("Table not found."); return; } // return exits the script ``` ## Existence and count checks ```csharp -// Does any measure reference CALCULATE? -bool usesCalc = Model.AllMeasures.Any(m => m.Expression.Contains("CALCULATE")); - // Are all columns documented? bool allDocs = table.Columns.All(c => !string.IsNullOrEmpty(c.Description)); @@ -93,7 +91,7 @@ int count = Model.AllColumns.Count(c => c.DataType == DataType.String); `Select()` transforms each element. Use it to extract property values or build new structures. ```csharp -// List of measure names +// List of measure names only (returns List) var names = Model.AllMeasures.Select(m => m.Name).ToList(); // Table name + measure count pairs @@ -114,20 +112,20 @@ Model.AllMeasures Model.Tables["Sales"].Measures.ForEach(m => m.DisplayFolder = "Sales Metrics"); ``` -## Materializing with ToList before deletion +## Materializing before modifying a collection -When you delete objects inside a loop, you modify the collection being iterated. This causes a collection-modified exception. Always call `.ToList()` first to create a snapshot. +When you modify objects inside a loop (delete, add, move), you change the collection being iterated. Always call `.ToList()` or `.ToArray()` first to create a snapshot. ```csharp // WRONG: modifying collection during iteration table.Measures.Where(m => m.IsHidden).ForEach(m => m.Delete()); // throws -// CORRECT: materialize first, then delete +// CORRECT: materialize first, then modify table.Measures.Where(m => m.IsHidden).ToList().ForEach(m => m.Delete()); ``` > [!WARNING] -> Always call `.ToList()` before `.ForEach(x => x.Delete())` or any operation that adds/removes objects from the collection being iterated. +> Failing to materialize causes: `"Collection was modified; enumeration operation may not complete."` This applies to any modification, not just deletion. ## Combining collections @@ -137,6 +135,11 @@ Use `Concat()` to merge collections and `Distinct()` to remove duplicates. // All hidden objects (measures + columns) in a table var hidden = table.Measures.Where(m => m.IsHidden).Cast() .Concat(table.Columns.Where(c => c.IsHidden).Cast()); + +// All unique tables referenced by selected measures +var tables = Selected.Measures + .Select(m => m.Table) + .Distinct(); ``` ## Dynamic LINQ equivalent diff --git a/content/how-tos/scripting-navigate-tom-hierarchy.md b/content/how-tos/scripting-navigate-tom-hierarchy.md index 86810988..e614d0ac 100644 --- a/content/how-tos/scripting-navigate-tom-hierarchy.md +++ b/content/how-tos/scripting-navigate-tom-hierarchy.md @@ -2,7 +2,7 @@ uid: how-to-navigate-tom-hierarchy title: How to Navigate the TOM Object Hierarchy author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -12,7 +12,7 @@ applies_to: --- # How to Navigate the TOM Object Hierarchy -Every C# script starts from the `Model` object, which is the root of the Tabular Object Model (TOM) hierarchy. This article shows how to reach any object in a semantic model. +Every C# script starts from the `Model` object or the @csharp-scripts `Selected` object. These expose the Tabular Editor TOM wrapper, which wraps the Microsoft Analysis Services Tabular Object Model (TOM). See the (xref:TabularEditor.TOMWrapper) API reference for the full wrapper documentation. ## Quick reference @@ -56,7 +56,7 @@ Use `FirstOrDefault()` when the object may not exist: ```csharp var table = Model.Tables.FirstOrDefault(t => t.Name == "Sales"); -if (table == null) { Error("Table not found"); return; } +if (table == null) { Error("Table not found"); return; } // return exits the script early ``` ## Navigating from child to parent @@ -71,8 +71,15 @@ var model = measure.Model; // The Model root var level = Model.AllLevels.First(); var hierarchy = level.Hierarchy; // parent hierarchy var table = level.Table; // parent table (via hierarchy) + +// Navigate up to Model and back down to a different table +var m = Model.AllMeasures.First(m => m.Name == "Revenue"); +var otherCol = m.Table.Model.Tables["Product"].Columns.First(); ``` +> [!NOTE] +> The last example demonstrates that you can navigate up to `Model` from any child object and back down to any table in the model. + ## Navigating table children Each `Table` exposes typed collections for its child objects. @@ -80,10 +87,10 @@ Each `Table` exposes typed collections for its child objects. ```csharp var table = Model.Tables["Sales"]; -table.Columns // ColumnCollection -table.Measures // MeasureCollection -table.Hierarchies // HierarchyCollection -table.Partitions // PartitionCollection +Output(table.Columns); // ColumnCollection +Output(table.Measures); // MeasureCollection +Output(table.Hierarchies); // HierarchyCollection +Output(table.Partitions); // PartitionCollection ``` ## Searching with predicates @@ -110,7 +117,7 @@ foreach (var cg in Model.CalculationGroups) { foreach (var item in cg.CalculationItems) { - // item.Name, item.Expression, item.Ordinal + Info(item.Name + ": " + item.Expression); } } ``` diff --git a/content/how-tos/scripting-perspectives-translations.md b/content/how-tos/scripting-perspectives-translations.md index b71c0219..78466656 100644 --- a/content/how-tos/scripting-perspectives-translations.md +++ b/content/how-tos/scripting-perspectives-translations.md @@ -2,7 +2,7 @@ uid: how-to-work-with-perspectives-translations title: How to Work with Perspectives and Translations author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 diff --git a/content/how-tos/scripting-tom-interfaces.md b/content/how-tos/scripting-tom-interfaces.md new file mode 100644 index 00000000..ec584a1c --- /dev/null +++ b/content/how-tos/scripting-tom-interfaces.md @@ -0,0 +1,108 @@ +--- +uid: how-to-tom-interfaces +title: Key TOM Interfaces +author: Morten Lønskov +updated: 2026-04-10 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# Key TOM Interfaces + +The TOM wrapper defines several cross-cutting interfaces that multiple object types implement. Use these interfaces when writing generic code that operates on any object with a given capability, such as setting descriptions, checking visibility or reading annotations. + +## Quick reference + +```csharp +// Set description on any object that supports it +foreach (var obj in Selected.OfType()) + obj.Description = "Reviewed"; + +// Hide any hideable object +foreach (var obj in Selected.OfType()) + obj.IsHidden = true; + +// Read annotations on any annotatable object +foreach (var obj in Model.AllMeasures.OfType()) + if (obj.HasAnnotation("Status")) Info(obj.GetAnnotation("Status")); +``` + +## Interface reference + +| Interface | Key members | Implemented by | +|---|---|---| +| (xref:TabularEditor.TOMWrapper.IDescriptionObject) | `Description` | Tables, columns, measures, hierarchies, partitions, relationships, perspectives, roles, data sources | +| (xref:TabularEditor.TOMWrapper.IHideableObject) | `IsHidden`, `IsVisible` | Tables, columns, measures, hierarchies, levels | +| (xref:TabularEditor.TOMWrapper.ITabularPerspectiveObject) | `InPerspective` indexer | Tables, columns, measures, hierarchies | +| (xref:TabularEditor.TOMWrapper.ITranslatableObject) | `TranslatedNames`, `TranslatedDescriptions` | Tables, columns, measures, hierarchies, levels | +| (xref:TabularEditor.TOMWrapper.IFolderObject) | `DisplayFolder`, `TranslatedDisplayFolders` | Measures, columns, hierarchies | +| (xref:TabularEditor.TOMWrapper.IAnnotationObject) | `GetAnnotation()`, `SetAnnotation()`, `HasAnnotation()`, `RemoveAnnotation()`, `Annotations` | Almost all TOM objects | +| (xref:TabularEditor.TOMWrapper.IExtendedPropertyObject) | `GetExtendedProperty()`, `SetExtendedProperty()`, `ExtendedProperties` | Tables, columns, measures, hierarchies, partitions | +| (xref:TabularEditor.TOMWrapper.IExpressionObject) | `Expression` (TE2); `GetExpression()`, `SetExpression()` (TE3) | Measures, calculated columns, calculation items, partitions, KPIs | +| (xref:TabularEditor.TOMWrapper.IDaxObject) | `DaxObjectName`, `DaxObjectFullName`, `ReferencedBy` | Tables, columns, measures | +| (xref:TabularEditor.TOMWrapper.IDaxDependantObject) | `DependsOn` | Measures, calculated columns, calculation items, KPIs, tables, partitions | + +## When to use interfaces + +Use interfaces when you need to write generic code that applies to multiple object types. Instead of checking each type individually: + +```csharp +// Without interfaces -- repetitive +foreach (var m in Selected.Measures) + m.Description = "Reviewed"; +foreach (var c in Selected.Columns) + c.Description = "Reviewed"; +foreach (var t in Selected.Tables) + t.Description = "Reviewed"; +``` + +Use `OfType()` with an interface to handle all types in one pass: + +```csharp +// With interfaces -- handles any object that has a Description +foreach (var obj in Selected.OfType()) + obj.Description = "Reviewed"; +``` + +## Common interface patterns + +### Check and set visibility + +```csharp +// Hide all selected objects that support hiding +Selected.OfType().ForEach(obj => obj.IsHidden = true); +``` + +### Set display folder across types + +```csharp +// Move all selected folder-bearing objects to a display folder +Selected.OfType().ForEach(obj => obj.DisplayFolder = "Archive"); +``` + +### Tag objects with annotations + +```csharp +// Tag any annotatable object +Selected.OfType().ForEach(obj => + obj.SetAnnotation("ReviewDate", DateTime.Today.ToString("yyyy-MM-dd"))); +``` + +### Find all objects with a DAX expression + +```csharp +// List all objects that have a DAX expression and depend on a specific table +var dependents = Model.AllMeasures.Cast() + .Concat(Model.AllColumns.OfType().Cast()) + .Where(obj => obj.DependsOn.Tables.Any(t => t.Name == "Date")); +``` + +## See also + +- @how-to-check-object-types +- @how-to-filter-query-objects-linq +- @how-to-annotations-extended-properties +- @how-to-work-with-dependencies diff --git a/content/how-tos/scripting-ui-helpers.md b/content/how-tos/scripting-ui-helpers.md index 6f32950e..495e5bca 100644 --- a/content/how-tos/scripting-ui-helpers.md +++ b/content/how-tos/scripting-ui-helpers.md @@ -2,7 +2,7 @@ uid: how-to-use-script-ui-helpers title: How to Use Script UI Helpers author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -234,6 +234,7 @@ WaitFormVisible = false; using (var form = new Form()) { + // --- Form setup: AutoSize + layout panel for DPI-safe scaling --- form.Text = "Create Measure"; form.AutoSize = true; form.AutoSizeMode = AutoSizeMode.GrowAndShrink; @@ -249,12 +250,11 @@ using (var form = new Form()) }; form.Controls.Add(layout); - // Name field + // --- Input fields: name and expression --- layout.Controls.Add(new Label { Text = "Measure name:", AutoSize = true }); var nameBox = new TextBox { Width = 400 }; layout.Controls.Add(nameBox); - // Expression field layout.Controls.Add(new Label { Text = "DAX expression:", AutoSize = true, Padding = new Padding(0, 10, 0, 0) @@ -262,7 +262,7 @@ using (var form = new Form()) var exprBox = new TextBox { Width = 400, Height = 80, Multiline = true }; layout.Controls.Add(exprBox); - // Buttons + // --- Buttons: OK/Cancel with keyboard support --- var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Dock = DockStyle.Fill, AutoSize = true, @@ -282,13 +282,14 @@ using (var form = new Form()) form.AcceptButton = okBtn; form.CancelButton = cancelBtn; - // Enable OK only when both fields have content + // --- Validation: enable OK only when both fields have content --- EventHandler validate = (s, e) => okBtn.Enabled = !string.IsNullOrWhiteSpace(nameBox.Text) && !string.IsNullOrWhiteSpace(exprBox.Text); nameBox.TextChanged += validate; exprBox.TextChanged += validate; + // --- Process result --- if (form.ShowDialog() == DialogResult.OK) { var table = Selected.Table; diff --git a/content/how-tos/scripting-use-selected-object.md b/content/how-tos/scripting-use-selected-object.md index f8f87b63..811f9b46 100644 --- a/content/how-tos/scripting-use-selected-object.md +++ b/content/how-tos/scripting-use-selected-object.md @@ -2,7 +2,7 @@ uid: how-to-use-selected-object title: How to Use the Selected Object author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -12,7 +12,7 @@ applies_to: --- # How to Use the Selected Object -The `Selected` object provides access to whatever is currently selected in the **TOM Explorer** tree. Use it to write scripts that operate on user-selected objects rather than hardcoded names. +The `Selected` object provides access to whatever is currently selected in the @tom-explorer-view-reference tree. Use it to write scripts that operate on user-selected objects rather than hardcoded names. ## Quick reference @@ -22,17 +22,6 @@ Selected.Measure Selected.Table Selected.Column -// Plural (zero or more, safe to iterate) -Selected.Measures -Selected.Tables -Selected.Columns -Selected.Hierarchies -Selected.Partitions -Selected.Levels -Selected.CalculationItems -Selected.Roles -Selected.DataSources - // Guard clause if (Selected.Measures.Count() == 0) { Error("Select at least one measure."); return; } @@ -44,6 +33,18 @@ foreach (var m in Selected.Measures) Selected.Measures.ForEach(m => m.DisplayFolder = "KPIs"); ``` +Plural accessors (zero or more, safe to iterate): + +- `Selected.Measures` +- `Selected.Tables` +- `Selected.Columns` +- `Selected.Hierarchies` +- `Selected.Partitions` +- `Selected.Levels` +- `Selected.CalculationItems` +- `Selected.Roles` +- `Selected.DataSources` + ## Singular vs plural accessors The `Selected` object exposes both singular and plural accessors for each object type. @@ -51,13 +52,13 @@ The `Selected` object exposes both singular and plural accessors for each object | Accessor | Returns | Behavior when count is not 1 | |---|---|---| | `Selected.Measure` | single `Measure` | Throws exception if 0 or 2+ measures selected | -| `Selected.Measures` | `IEnumerable` | Returns empty collection if none selected | +| `Selected.Measures` | `IEnumerable` | Returns a collection that may be empty but is never null. Safe to iterate directly. | Use the **singular** form when your script requires exactly one object. Use the **plural** form when the script should work on one or more objects. ## Guard clauses -Always validate the selection before performing operations. This prevents confusing error messages. +The plural accessor returns zero or more objects. A script may silently do nothing with an empty collection, or require a minimum count. Use a guard clause for the latter. ```csharp // Require at least one measure @@ -66,7 +67,9 @@ if (Selected.Measures.Count() == 0) Error("No measures selected. Select one or more measures and run again."); return; } +``` +```csharp // Require exactly one table if (Selected.Tables.Count() != 1) { @@ -107,13 +110,8 @@ Selected.Measures.ForEach(m => m.InPerspective["Sales"] = true); When a single table is selected, use `Selected.Table` to add new objects to it. ```csharp -if (Selected.Tables.Count() != 1) { Error("Select one table."); return; } - -var table = Selected.Table; -var newMeasure = table.AddMeasure( - "Row Count", - "COUNTROWS(" + table.DaxObjectFullName + ")" -); +var t = Selected.Table; +t.AddMeasure("Row Count", "COUNTROWS(" + t.DaxObjectFullName + ")"); ``` ## Mixed selections @@ -121,13 +119,14 @@ var newMeasure = table.AddMeasure( When you need to handle multiple object types from the selection, iterate `Selected` directly. The `Selected` variable itself implements `IEnumerable`. ```csharp -foreach (var obj in Selected) +foreach (var desc in Selected.OfType()) { - if (obj is IDescriptionObject desc) - desc.Description = "Reviewed on " + DateTime.Today.ToString("yyyy-MM-dd"); + desc.Description = "Reviewed on " + DateTime.Today.ToString("yyyy-MM-dd"); } ``` +See @how-to-filter-query-objects-linq for more on LINQ filtering and @how-to-tom-interfaces for interface-based object handling. + ## Try/catch for selection dialogs When using `SelectTable()`, `SelectColumn()`, or `SelectMeasure()` helper methods, wrap them in try/catch to handle user cancellation. diff --git a/content/how-tos/scripting-work-with-annotations.md b/content/how-tos/scripting-work-with-annotations.md index e337e4ff..084134b2 100644 --- a/content/how-tos/scripting-work-with-annotations.md +++ b/content/how-tos/scripting-work-with-annotations.md @@ -1,8 +1,8 @@ --- -uid: how-to-work-with-annotations +uid: how-to-annotations-extended-properties title: How to Work with Annotations and Extended Properties author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -12,7 +12,7 @@ applies_to: --- # How to Work with Annotations and Extended Properties -Annotations and extended properties store custom metadata on TOM objects. Annotations are string key-value pairs. Extended properties support both string and JSON types. Both are persisted in the model and survive deployment. +Annotations are informational-only metadata with no impact on model behavior. They are useful for automation and scripting. Extended properties are intended for client tool extensions that require specific support. For example, field parameters in Power BI depend on extended properties, which is why this feature is Power BI-only. ## Quick reference @@ -38,12 +38,13 @@ obj.ExtendedProperties // ExtendedPropertyCollection (index ## Setting and reading annotations -Any object implementing `IAnnotationObject` supports annotations. This includes tables, columns, measures, hierarchies, partitions, perspectives, roles, data sources and relationships. +Any object implementing (xref:TabularEditor.TOMWrapper.IAnnotationObject) supports annotations. This includes tables, columns, measures, hierarchies, partitions, perspectives, roles, data sources and relationships. + +Tag auto-generated measures so a future script can identify and update them: ```csharp -// Tag a measure for automation var m = Model.AllMeasures.First(m => m.Name == "Revenue"); -m.SetAnnotation("AUTOGEN", "true"); +m.SetAnnotation("GeneratedBy", "DateTableScript"); m.SetAnnotation("Owner", "Finance Team"); // Read it back @@ -51,6 +52,12 @@ string owner = m.GetAnnotation("Owner"); // "Finance Team" string missing = m.GetAnnotation("NoKey"); // null ``` +Later, retrieve all measures tagged by that script: + +```csharp +var autoGenerated = Model.AllMeasures.Where(m => m.GetAnnotation("GeneratedBy") == "DateTableScript"); +``` + ## Checking and removing annotations ```csharp @@ -92,10 +99,13 @@ Model.AllMeasures .Where(m => m.IsHidden) .ForEach(m => m.SetAnnotation("ReviewStatus", "Hidden")); -// Remove a specific annotation from all objects that have it +// Migrate an annotation key from OldKey to NewKey Model.AllMeasures - .Where(m => m.HasAnnotation("OLD_TAG")) - .ForEach(m => m.RemoveAnnotation("OLD_TAG")); + .Where(m => m.HasAnnotation("OldKey")) + .ForEach(m => { + m.SetAnnotation("NewKey", m.GetAnnotation("OldKey")); + m.RemoveAnnotation("OldKey"); + }); ``` ## Extended properties @@ -111,6 +121,10 @@ table.SetExtendedProperty("ParameterMetadata", json, ExtendedPropertyType.Json); // Read back string value = table.GetExtendedProperty("ParameterMetadata"); var type = table.GetExtendedPropertyType("ParameterMetadata"); // ExtendedPropertyType.Json + +// Indexer access +table.ExtendedProperties["key"] = "value"; +string val = table.ExtendedProperties["key"]; ``` ## Dynamic LINQ equivalent diff --git a/content/how-tos/scripting-work-with-dependencies.md b/content/how-tos/scripting-work-with-dependencies.md index 2dd24193..0921829f 100644 --- a/content/how-tos/scripting-work-with-dependencies.md +++ b/content/how-tos/scripting-work-with-dependencies.md @@ -2,7 +2,7 @@ uid: how-to-work-with-dependencies title: How to Work with Dependencies author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -14,6 +14,9 @@ applies_to: The TOM wrapper tracks which objects reference which other objects through the `DependsOn` and `ReferencedBy` properties. Use these for impact analysis, finding unused objects and understanding DAX lineage. +> [!NOTE] +> The `DependsOn` and `ReferencedBy` properties expose the same dependency information shown in the **Dependency View** in Tabular Editor's UI. + ## Quick reference ```csharp @@ -48,7 +51,7 @@ column.UsedInSortBy // columns using this as SortByColumn ## DependsOn: what does this object reference? -`DependsOn` is available on any `IDaxDependantObject`: measures, calculated columns, calculation items, KPIs, tables and partitions. +`DependsOn` is available on (xref:TabularEditor.TOMWrapper.IDaxDependantObject) types -- objects that have a DAX expression. This includes measures, calculated columns, calculation items, KPIs, tables and partitions. ```csharp var measure = Model.AllMeasures.First(m => m.Name == "Revenue"); @@ -63,7 +66,7 @@ bool usesDate = measure.DependsOn.Tables.Any(t => t.Name == "Date"); ## ReferencedBy: what references this object? -`ReferencedBy` is available on any `IDaxObject`: columns, measures and tables. +`ReferencedBy` is available on any (xref:TabularEditor.TOMWrapper.IDaxObject). This includes objects with no DAX expression of their own, such as `DataColumn`, which can still be referenced by name in other objects' DAX. ```csharp var column = Model.Tables["Sales"].Columns["Amount"]; @@ -93,7 +96,7 @@ var affectedMeasures = allDownstream.OfType(); ## Finding unused objects -Objects with no references are candidates for cleanup. +Objects with no references are candidates for cleanup. This pattern mirrors the built-in BPA rule for detecting unused objects. ```csharp // Measures not referenced by any other DAX expression @@ -144,8 +147,8 @@ In BPA rule expressions, dependency properties are accessed directly on the obje ## Common pitfalls > [!IMPORTANT] -> - `DependsOn` is only available on `IDaxDependantObject` types: `Measure`, `CalculatedColumn`, `CalculationItem`, `KPI`, `Table`, `Partition`, `TablePermission`. A `DataColumn` does not have `DependsOn` because it has no DAX expression. -> - `ReferencedBy` is only available on `IDaxObject` types: `Column`, `Measure`, `Table`, `Hierarchy`. Not every object type has both properties. +> - `DependsOn` requires a DAX expression and is only available on `IDaxDependantObject` types: `Measure`, `CalculatedColumn`, `CalculationItem`, `KPI`, `Table`, `Partition`, `TablePermission`. A `DataColumn` does not have `DependsOn` because it has no DAX expression. +> - `ReferencedBy` does not require a DAX expression. It is available on any `IDaxObject` type: `Column`, `Measure`, `Table`, `Hierarchy`. A `DataColumn` has `ReferencedBy` because other objects can reference it by name. Not every object type has both properties. > - `UsedInRelationships`, `UsedInHierarchies` and `UsedInSortBy` are column-specific properties. They track structural usage, not DAX expression references. Check both structural and DAX references to find truly unused columns. > - `ReferencedBy.Deep()` and `DependsOn.Deep()` can be computationally expensive on large models with deeply nested dependency chains. diff --git a/content/how-tos/scripting-work-with-expressions.md b/content/how-tos/scripting-work-with-expressions.md index 812d1aaf..15e101c5 100644 --- a/content/how-tos/scripting-work-with-expressions.md +++ b/content/how-tos/scripting-work-with-expressions.md @@ -2,7 +2,7 @@ uid: how-to-work-with-expressions title: How to Work with Expressions and DAX Properties author: Morten Lønskov -updated: 2026-04-09 +updated: 2026-04-10 applies_to: products: - product: Tabular Editor 2 @@ -89,7 +89,7 @@ foreach (var col in Selected.Columns) ## The IExpressionObject interface -Objects that hold expressions implement `IExpressionObject`. In Tabular Editor 2, this interface provides only the `Expression` property. In Tabular Editor 3, it adds `GetExpression()`, `SetExpression()` and `GetExpressionProperties()` for working with multiple expression types on a single object. +Objects that hold expressions implement (xref:TabularEditor.TOMWrapper.IExpressionObject). In Tabular Editor 2, this interface provides only the `Expression` property. In Tabular Editor 3, it adds `GetExpression()`, `SetExpression()` and `GetExpressionProperties()` for working with multiple expression types on a single object. ```csharp // Tabular Editor 2: use the Expression property directly @@ -129,18 +129,23 @@ The `ExpressionProperty` enum (Tabular Editor 3 only) includes: ## Formatting DAX -Use `FormatDax()` to queue objects for formatting and `CallDaxFormatter()` to execute. +`FormatDax()` queues objects for formatting. Formatting executes automatically at the end of the script. Call `CallDaxFormatter()` only when you need the formatted result mid-script. ```csharp -// Format all measures in the model +// Typical usage -- formatting happens automatically after the script ends foreach (var m in Model.AllMeasures) FormatDax(m); -CallDaxFormatter(); + +// Advanced: force formatting mid-script to read the result +var before = Selected.Measure.Expression; +FormatDax(Selected.Measure); +CallDaxFormatter(); // format NOW, not at script end +var after = Selected.Measure.Expression; // now contains the formatted DAX ``` -## Tokenizing for complexity analysis +## Tokenizing -`Tokenize()` returns the DAX tokens in an expression. Use it to measure expression complexity. +`Tokenize()` returns the DAX tokens in an expression. Tokens provide a reliable representation independent of whitespace and formatting. Use tokenization when you need to analyze the structure of a DAX expression beyond what the built-in dependency tracking and rename support already provides. ```csharp foreach (var m in Model.AllMeasures.OrderByDescending(m => m.Tokenize().Count)) @@ -149,6 +154,8 @@ foreach (var m in Model.AllMeasures.OrderByDescending(m => m.Tokenize().Count)) ## Find and replace in expressions +String replacement with `Replace()` operates on the raw expression text, including inside string literals and comments. For targeted replacement of specific DAX constructs (table references, column references), analyze the tokenized expression instead. + ```csharp // Replace a column reference across all measures foreach (var m in Model.AllMeasures.Where(m => m.Expression.Contains("[Old Column]"))) @@ -173,7 +180,7 @@ In BPA rule expressions, expression properties are accessed directly on the obje > [!IMPORTANT] > - `DataColumn` does not have an `Expression` property. Only `CalculatedColumn`, `Measure`, `CalculationItem` and `Partition` have expressions. Accessing `Expression` on a `DataColumn` causes a compile error or runtime exception depending on context. > - `DaxObjectName` returns the unqualified name (e.g., `[Revenue]`) while `DaxObjectFullName` includes the table prefix (e.g., `'Sales'[Revenue]`). Use `DaxObjectFullName` for column references in DAX and `DaxObjectName` for measure references where table qualification is optional. -> - `FormatDax()` only queues the object. You must call `CallDaxFormatter()` to actually format the expressions. The formatter requires an internet connection (it calls the daxformatter.com API). +> - `FormatDax()` in Tabular Editor 2 calls the external daxformatter.com API and requires an internet connection. Tabular Editor 3 uses a built-in formatter by default. To use daxformatter.com in TE3, enable it in preferences. ## See also diff --git a/content/how-tos/toc.md b/content/how-tos/toc.md index 230624e4..37f732cd 100644 --- a/content/how-tos/toc.md +++ b/content/how-tos/toc.md @@ -5,15 +5,16 @@ # Scripting Patterns ## [Navigate the TOM Object Hierarchy](scripting-navigate-tom-hierarchy.md) ## [Check Object Types](scripting-check-object-types.md) -## [Use the Selected Object](scripting-use-selected-object.md) +## [Key TOM Interfaces](scripting-tom-interfaces.md) +## [Dynamic LINQ vs C# LINQ](scripting-dynamic-linq-vs-csharp.md) ## [Filter and Query Objects with LINQ](scripting-filter-query-linq.md) +## [Use the Selected Object](scripting-use-selected-object.md) +## [Work with Expressions and DAX Properties](scripting-work-with-expressions.md) ## [Work with Dependencies](scripting-work-with-dependencies.md) ## [Work with Annotations and Extended Properties](scripting-work-with-annotations.md) ## [Work with Perspectives and Translations](scripting-perspectives-translations.md) -## [Work with Expressions and DAX Properties](scripting-work-with-expressions.md) ## [Add, Clone and Remove Objects](scripting-add-clone-remove-objects.md) ## [Use Script UI Helpers](scripting-ui-helpers.md) -## [Dynamic LINQ vs C# LINQ](scripting-dynamic-linq-vs-csharp.md) # Model Management and Deployment ## [Deploy Current Model](deploy-current-model.md) From af07874ca201ef1bd8206583a49565d655d43e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20L=C3=B8nskov?= Date: Mon, 13 Apr 2026 12:30:40 +0200 Subject: [PATCH 4/5] Add compiler link --- content/how-tos/scripting-check-object-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/how-tos/scripting-check-object-types.md b/content/how-tos/scripting-check-object-types.md index c598d5c1..f239ce09 100644 --- a/content/how-tos/scripting-check-object-types.md +++ b/content/how-tos/scripting-check-object-types.md @@ -30,7 +30,7 @@ string typeName = obj.GetType().Name; // "DataColumn", "Measure", etc. ``` > [!NOTE] -> Pattern matching with variable declaration (`col is CalculatedColumn cc`) requires the Roslyn compiler in Tabular Editor 2. Enable it under **File > Preferences > General > Use Roslyn compiler**. Tabular Editor 3 supports this by default. +> Pattern matching with variable declaration (`col is CalculatedColumn cc`) requires the Roslyn compiler in Tabular Editor 2. Configure it under **File > Preferences > General > Compiler path**. See [Compiling with Roslyn](xref:advanced-scripting#compiling-with-roslyn) for details. Tabular Editor 3 supports this by default. ## Type hierarchy From 7ed24a4f1381d6a7871d6d33fdf4cad675268db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20L=C3=B8nskov?= Date: Mon, 13 Apr 2026 17:06:22 +0200 Subject: [PATCH 5/5] Greg recomendations round 2 --- .../scripting-custom-winforms-dialogs.md | 251 ++++++++++++++++++ .../scripting-dynamic-linq-vs-csharp.md | 4 +- .../scripting-perspectives-translations.md | 10 +- content/how-tos/scripting-ui-helpers.md | 246 +---------------- .../scripting-work-with-annotations.md | 18 +- content/how-tos/toc.md | 1 + 6 files changed, 287 insertions(+), 243 deletions(-) create mode 100644 content/how-tos/scripting-custom-winforms-dialogs.md diff --git a/content/how-tos/scripting-custom-winforms-dialogs.md b/content/how-tos/scripting-custom-winforms-dialogs.md new file mode 100644 index 00000000..80524521 --- /dev/null +++ b/content/how-tos/scripting-custom-winforms-dialogs.md @@ -0,0 +1,251 @@ +--- +uid: how-to-build-custom-winforms-dialogs +title: How to Build Custom WinForms Dialogs in Scripts +author: Morten Lønskov +updated: 2026-04-13 +applies_to: + products: + - product: Tabular Editor 2 + full: true + - product: Tabular Editor 3 + full: true +--- +# How to Build Custom WinForms Dialogs in Scripts + +For input scenarios beyond what `SelectTable()`, `SelectMeasure()` and the other built-in helpers provide, build custom WinForms dialogs directly in a C# script. Use `TableLayoutPanel` and `FlowLayoutPanel` with `AutoSize` for proper scaling across DPI settings. + +> [!WARNING] +> Do not use manual pixel positioning with `Location = new Point(x, y)` for custom dialogs. This approach breaks at non-standard DPI settings. Use layout panels instead. + +## Simple prompt dialog + +A single-field prompt with OK/Cancel buttons. Use this pattern when you need one piece of user input. + +```csharp +using System.Windows.Forms; +using System.Drawing; + +WaitFormVisible = false; + +using (var form = new Form()) +{ + form.Text = "Enter a value"; + form.AutoSize = true; + form.AutoSizeMode = AutoSizeMode.GrowAndShrink; + form.FormBorderStyle = FormBorderStyle.FixedDialog; + form.MaximizeBox = false; + form.MinimizeBox = false; + form.StartPosition = FormStartPosition.CenterParent; + form.Padding = new Padding(20); + + var layout = new TableLayoutPanel { + ColumnCount = 1, Dock = DockStyle.Fill, + AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink + }; + form.Controls.Add(layout); + + layout.Controls.Add(new Label { Text = "Display folder name:", AutoSize = true }); + var textBox = new TextBox { Width = 300, Text = "New Folder" }; + layout.Controls.Add(textBox); + + var buttons = new FlowLayoutPanel { + FlowDirection = FlowDirection.LeftToRight, + Dock = DockStyle.Fill, AutoSize = true, + Padding = new Padding(0, 10, 0, 0) + }; + var okBtn = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK }; + var cancelBtn = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + buttons.Controls.AddRange(new Control[] { okBtn, cancelBtn }); + layout.Controls.Add(buttons); + + form.AcceptButton = okBtn; + form.CancelButton = cancelBtn; + + if (form.ShowDialog() == DialogResult.OK) + { + Selected.Measures.ForEach(m => m.DisplayFolder = textBox.Text); + Info("Updated display folder to: " + textBox.Text); + } +} +``` + +## Multi-field form with validation + +Extend the prompt pattern to multiple fields. Use a change event to enable the OK button only when all required fields have content. + +The block structure below mirrors the order you should follow when writing dialog scripts: form setup, input fields, buttons, validation and result handling. + +```csharp +using System.Windows.Forms; +using System.Drawing; + +WaitFormVisible = false; + +using (var form = new Form()) +{ + // --- Form setup: AutoSize + layout panel for DPI-safe scaling --- + form.Text = "Create Measure"; + form.AutoSize = true; + form.AutoSizeMode = AutoSizeMode.GrowAndShrink; + form.StartPosition = FormStartPosition.CenterParent; + form.FormBorderStyle = FormBorderStyle.FixedDialog; + form.MaximizeBox = false; + form.MinimizeBox = false; + form.Padding = new Padding(20); + + var layout = new TableLayoutPanel { + ColumnCount = 1, Dock = DockStyle.Fill, + AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink + }; + form.Controls.Add(layout); + + // --- Input fields: name and expression --- + layout.Controls.Add(new Label { Text = "Measure name:", AutoSize = true }); + var nameBox = new TextBox { Width = 400 }; + layout.Controls.Add(nameBox); + + layout.Controls.Add(new Label { + Text = "DAX expression:", AutoSize = true, + Padding = new Padding(0, 10, 0, 0) + }); + var exprBox = new TextBox { Width = 400, Height = 80, Multiline = true }; + layout.Controls.Add(exprBox); + + // --- Buttons: OK/Cancel with keyboard support --- + var buttons = new FlowLayoutPanel { + FlowDirection = FlowDirection.LeftToRight, + Dock = DockStyle.Fill, AutoSize = true, + Padding = new Padding(0, 10, 0, 0) + }; + var okBtn = new Button { + Text = "OK", AutoSize = true, + DialogResult = DialogResult.OK, Enabled = false + }; + var cancelBtn = new Button { + Text = "Cancel", AutoSize = true, + DialogResult = DialogResult.Cancel + }; + buttons.Controls.AddRange(new Control[] { okBtn, cancelBtn }); + layout.Controls.Add(buttons); + + form.AcceptButton = okBtn; + form.CancelButton = cancelBtn; + + // --- Validation: enable OK only when both fields have content --- + EventHandler validate = (s, e) => + okBtn.Enabled = !string.IsNullOrWhiteSpace(nameBox.Text) + && !string.IsNullOrWhiteSpace(exprBox.Text); + nameBox.TextChanged += validate; + exprBox.TextChanged += validate; + + // --- Process result --- + if (form.ShowDialog() == DialogResult.OK) + { + var table = Selected.Table; + table.AddMeasure(nameBox.Text.Trim(), exprBox.Text.Trim()); + Info("Created measure: " + nameBox.Text.Trim()); + } +} +``` + +## Scope selection dialog (reusable class) + +For scripts that need a choice between operating on selected objects or all objects, encapsulate the dialog in a reusable class. + +```csharp +using System.Windows.Forms; +using System.Drawing; + +public class ScopeDialog : Form +{ + public enum ScopeOption { OnlySelected, All, Cancel } + public ScopeOption SelectedOption { get; private set; } + + public ScopeDialog(int selectedCount, int totalCount) + { + Text = "Choose scope"; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Padding = new Padding(20); + + var layout = new TableLayoutPanel { + ColumnCount = 1, Dock = DockStyle.Fill, + AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink + }; + Controls.Add(layout); + + layout.Controls.Add(new Label { + Text = $"{selectedCount} object(s) selected out of {totalCount} total.", + AutoSize = true + }); + + var buttons = new FlowLayoutPanel { + FlowDirection = FlowDirection.LeftToRight, + Dock = DockStyle.Fill, AutoSize = true, + Padding = new Padding(0, 15, 0, 0) + }; + + var btnSelected = new Button { + Text = "Only selected", AutoSize = true, + DialogResult = DialogResult.OK + }; + btnSelected.Click += (s, e) => SelectedOption = ScopeOption.OnlySelected; + + var btnAll = new Button { + Text = "All objects", AutoSize = true, + DialogResult = DialogResult.Yes + }; + btnAll.Click += (s, e) => SelectedOption = ScopeOption.All; + + var btnCancel = new Button { + Text = "Cancel", AutoSize = true, + DialogResult = DialogResult.Cancel + }; + btnCancel.Click += (s, e) => SelectedOption = ScopeOption.Cancel; + + buttons.Controls.AddRange(new Control[] { btnSelected, btnAll, btnCancel }); + layout.Controls.Add(buttons); + + AcceptButton = btnSelected; + CancelButton = btnCancel; + } +} + +// Usage: +WaitFormVisible = false; +using (var dialog = new ScopeDialog(Selected.Measures.Count(), Model.AllMeasures.Count())) +{ + dialog.ShowDialog(); + switch (dialog.SelectedOption) + { + case ScopeDialog.ScopeOption.OnlySelected: + Selected.Measures.ForEach(m => m.FormatString = "#,##0.00"); + break; + case ScopeDialog.ScopeOption.All: + Model.AllMeasures.ForEach(m => m.FormatString = "#,##0.00"); + break; + case ScopeDialog.ScopeOption.Cancel: + break; + } +} +``` + +## Key rules for scaling-safe dialogs + +- Set `AutoSize = true` and `AutoSizeMode = AutoSizeMode.GrowAndShrink` on the form. +- Use `TableLayoutPanel` (vertical stacking) and `FlowLayoutPanel` (horizontal button rows) instead of manual coordinates. +- Set `FormBorderStyle = FormBorderStyle.FixedDialog` and disable maximize/minimize. +- Set `StartPosition = FormStartPosition.CenterParent`. +- Always set `AcceptButton` and `CancelButton` for keyboard support (Enter/Escape). +- Call `WaitFormVisible = false` before showing a dialog to hide the "Running Macro" spinner. +- Wrap the form in a `using` statement for proper disposal. + +## See also + +- @how-to-use-script-ui-helpers +- @csharp-scripts +- @script-helper-methods diff --git a/content/how-tos/scripting-dynamic-linq-vs-csharp.md b/content/how-tos/scripting-dynamic-linq-vs-csharp.md index 93141103..60bb8da7 100644 --- a/content/how-tos/scripting-dynamic-linq-vs-csharp.md +++ b/content/how-tos/scripting-dynamic-linq-vs-csharp.md @@ -21,7 +21,9 @@ C# scripts use standard C# LINQ with lambda expressions. Best Practice Analyzer | C# scripts and macros | C# LINQ | | BPA rule expressions | Dynamic LINQ | | BPA fix expressions | Dynamic LINQ (with `it.` prefix for assignments) | -| **TOM Explorer** tree filter (`:` prefix) | Dynamic LINQ | +| **TOM Explorer** tree filter (`:` prefix)\* | Dynamic LINQ | + +\* Tabular Editor 2 only. ## Syntax comparison diff --git a/content/how-tos/scripting-perspectives-translations.md b/content/how-tos/scripting-perspectives-translations.md index 78466656..ffcae22f 100644 --- a/content/how-tos/scripting-perspectives-translations.md +++ b/content/how-tos/scripting-perspectives-translations.md @@ -12,7 +12,7 @@ applies_to: --- # How to Work with Perspectives and Translations -Perspectives control which objects are visible in specific client views. Translations (cultures) provide localized names, descriptions and display folders. Both use indexer properties on TOM objects. +Perspectives control which objects are visible in specific client views. Translations (cultures) provide localized names, descriptions and display folders. Both use indexer properties on TOM objects. See @how-to-navigate-tom-hierarchy for details on accessing TOM objects and their indexers. ## Quick reference @@ -20,7 +20,7 @@ Perspectives control which objects are visible in specific client views. Transla // Perspectives measure.InPerspective["Sales"] = true; // include in perspective measure.InPerspective["Sales"] = false; // exclude from perspective -bool isIn = measure.InPerspective["Sales"]; // check membership +var isIn = measure.InPerspective["Sales"]; // check membership // Translations measure.TranslatedNames["da-DK"] = "Omsætning"; // set translated name @@ -36,7 +36,7 @@ foreach (var culture in Model.Cultures) ## Setting perspective membership -The `InPerspective` indexer is available on tables, columns, measures and hierarchies (any `ITabularPerspectiveObject`). +The `InPerspective` indexer is available on tables, columns, measures and hierarchies (any (xref:TabularEditor.TOMWrapper.ITabularPerspectiveObject)). ```csharp // Add all measures in a table to a perspective @@ -62,7 +62,7 @@ foreach (var p in Model.Perspectives) ## Creating and removing perspectives ```csharp -// Create a new perspective +// Create a new perspective (empty upon creation -- add objects as shown above) var p = Model.AddPerspective("Executive Dashboard"); // Remove a perspective @@ -71,7 +71,7 @@ Model.Perspectives["Old View"].Delete(); ## Setting translations -Translation indexers are available on objects implementing `ITranslatableObject` (tables, columns, measures, hierarchies, levels). Display folder translations require `IFolderObject` (measures, columns, hierarchies). +Translation indexers are available on objects implementing (xref:TabularEditor.TOMWrapper.ITranslatableObject) (tables, columns, measures, hierarchies, levels). Display folder translations require (xref:TabularEditor.TOMWrapper.IFolderObject) (measures, columns, hierarchies). ```csharp var m = Model.AllMeasures.First(m => m.Name == "Revenue"); diff --git a/content/how-tos/scripting-ui-helpers.md b/content/how-tos/scripting-ui-helpers.md index 495e5bca..4baf6400 100644 --- a/content/how-tos/scripting-ui-helpers.md +++ b/content/how-tos/scripting-ui-helpers.md @@ -22,21 +22,22 @@ Info("Operation completed."); // informational popup Warning("This might take a while."); // warning popup Error("No valid selection."); return; // error popup + stop script -// Output -Output(measure); // property grid for a TOM object -Output(listOfMeasures); // list view with property grid -Output(dataTable); // sortable/filterable grid Output("Hello"); // simple dialog -// Object selection dialogs +// Object selection dialogs (capture returns for reuse below) var table = SelectTable(); // pick a table var column = SelectColumn(table.Columns); // pick from filtered columns var measure = SelectMeasure(); // pick a measure -var obj = SelectObject(Model.DataSources); // generic selection -var items = SelectObjects(collection); // multi-select (TE3 only) +var ds = SelectObject(Model.DataSources); // generic selection +var items = SelectObjects(Model.AllMeasures); // multi-select (TE3 only) // Evaluate DAX var result = EvaluateDax("COUNTROWS('Sales')"); // run DAX on connected model + +// Output (uses the variables assigned above) +Output(measure); // property grid for a TOM object +Output(items); // list view with property grid +Output(result); // sortable/filterable grid for a DataTable ``` ## Messages: Info, Warning, Error @@ -65,6 +66,9 @@ Info("Updated " + Selected.Measures.Count() + " measures."); | `DataTable` | Sortable, filterable grid | | String or primitive | Simple message dialog | +> [!NOTE] +> String output uses Windows line endings. Use `\r\n` or `Environment.NewLine` to insert line breaks. A bare `\n` renders on one line. This catches users out with M expressions, which use `\n` and print as a single line in `Output()`. + ### DataTable for structured output ```csharp @@ -169,237 +173,15 @@ else ## Custom WinForms dialogs -For more complex input, build WinForms dialogs. Use `TableLayoutPanel` and `FlowLayoutPanel` with `AutoSize` for proper scaling across DPI settings. - -> [!WARNING] -> Do not use manual pixel positioning with `Location = new Point(x, y)` for custom dialogs. This approach breaks at non-standard DPI settings. Use layout panels instead. - -### Simple prompt dialog - -```csharp -using System.Windows.Forms; -using System.Drawing; - -WaitFormVisible = false; - -using (var form = new Form()) -{ - form.Text = "Enter a value"; - form.AutoSize = true; - form.AutoSizeMode = AutoSizeMode.GrowAndShrink; - form.FormBorderStyle = FormBorderStyle.FixedDialog; - form.MaximizeBox = false; - form.MinimizeBox = false; - form.StartPosition = FormStartPosition.CenterParent; - form.Padding = new Padding(20); - - var layout = new TableLayoutPanel { - ColumnCount = 1, Dock = DockStyle.Fill, - AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink - }; - form.Controls.Add(layout); - - layout.Controls.Add(new Label { Text = "Display folder name:", AutoSize = true }); - var textBox = new TextBox { Width = 300, Text = "New Folder" }; - layout.Controls.Add(textBox); - - var buttons = new FlowLayoutPanel { - FlowDirection = FlowDirection.LeftToRight, - Dock = DockStyle.Fill, AutoSize = true, - Padding = new Padding(0, 10, 0, 0) - }; - var okBtn = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK }; - var cancelBtn = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; - buttons.Controls.AddRange(new Control[] { okBtn, cancelBtn }); - layout.Controls.Add(buttons); - - form.AcceptButton = okBtn; - form.CancelButton = cancelBtn; - - if (form.ShowDialog() == DialogResult.OK) - { - Selected.Measures.ForEach(m => m.DisplayFolder = textBox.Text); - Info("Updated display folder to: " + textBox.Text); - } -} -``` - -### Multi-field form with validation - -```csharp -using System.Windows.Forms; -using System.Drawing; - -WaitFormVisible = false; - -using (var form = new Form()) -{ - // --- Form setup: AutoSize + layout panel for DPI-safe scaling --- - form.Text = "Create Measure"; - form.AutoSize = true; - form.AutoSizeMode = AutoSizeMode.GrowAndShrink; - form.StartPosition = FormStartPosition.CenterParent; - form.FormBorderStyle = FormBorderStyle.FixedDialog; - form.MaximizeBox = false; - form.MinimizeBox = false; - form.Padding = new Padding(20); - - var layout = new TableLayoutPanel { - ColumnCount = 1, Dock = DockStyle.Fill, - AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink - }; - form.Controls.Add(layout); - - // --- Input fields: name and expression --- - layout.Controls.Add(new Label { Text = "Measure name:", AutoSize = true }); - var nameBox = new TextBox { Width = 400 }; - layout.Controls.Add(nameBox); - - layout.Controls.Add(new Label { - Text = "DAX expression:", AutoSize = true, - Padding = new Padding(0, 10, 0, 0) - }); - var exprBox = new TextBox { Width = 400, Height = 80, Multiline = true }; - layout.Controls.Add(exprBox); - - // --- Buttons: OK/Cancel with keyboard support --- - var buttons = new FlowLayoutPanel { - FlowDirection = FlowDirection.LeftToRight, - Dock = DockStyle.Fill, AutoSize = true, - Padding = new Padding(0, 10, 0, 0) - }; - var okBtn = new Button { - Text = "OK", AutoSize = true, - DialogResult = DialogResult.OK, Enabled = false - }; - var cancelBtn = new Button { - Text = "Cancel", AutoSize = true, - DialogResult = DialogResult.Cancel - }; - buttons.Controls.AddRange(new Control[] { okBtn, cancelBtn }); - layout.Controls.Add(buttons); - - form.AcceptButton = okBtn; - form.CancelButton = cancelBtn; - - // --- Validation: enable OK only when both fields have content --- - EventHandler validate = (s, e) => - okBtn.Enabled = !string.IsNullOrWhiteSpace(nameBox.Text) - && !string.IsNullOrWhiteSpace(exprBox.Text); - nameBox.TextChanged += validate; - exprBox.TextChanged += validate; - - // --- Process result --- - if (form.ShowDialog() == DialogResult.OK) - { - var table = Selected.Table; - table.AddMeasure(nameBox.Text.Trim(), exprBox.Text.Trim()); - Info("Created measure: " + nameBox.Text.Trim()); - } -} -``` - -### Scope selection dialog (reusable class) - -For scripts that need a choice between operating on selected objects or all objects, create a reusable dialog class. - -```csharp -using System.Windows.Forms; -using System.Drawing; - -public class ScopeDialog : Form -{ - public enum ScopeOption { OnlySelected, All, Cancel } - public ScopeOption SelectedOption { get; private set; } - - public ScopeDialog(int selectedCount, int totalCount) - { - Text = "Choose scope"; - AutoSize = true; - AutoSizeMode = AutoSizeMode.GrowAndShrink; - StartPosition = FormStartPosition.CenterParent; - FormBorderStyle = FormBorderStyle.FixedDialog; - MaximizeBox = false; - MinimizeBox = false; - Padding = new Padding(20); - - var layout = new TableLayoutPanel { - ColumnCount = 1, Dock = DockStyle.Fill, - AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink - }; - Controls.Add(layout); - - layout.Controls.Add(new Label { - Text = $"{selectedCount} object(s) selected out of {totalCount} total.", - AutoSize = true - }); - - var buttons = new FlowLayoutPanel { - FlowDirection = FlowDirection.LeftToRight, - Dock = DockStyle.Fill, AutoSize = true, - Padding = new Padding(0, 15, 0, 0) - }; - - var btnSelected = new Button { - Text = "Only selected", AutoSize = true, - DialogResult = DialogResult.OK - }; - btnSelected.Click += (s, e) => SelectedOption = ScopeOption.OnlySelected; - - var btnAll = new Button { - Text = "All objects", AutoSize = true, - DialogResult = DialogResult.Yes - }; - btnAll.Click += (s, e) => SelectedOption = ScopeOption.All; - - var btnCancel = new Button { - Text = "Cancel", AutoSize = true, - DialogResult = DialogResult.Cancel - }; - btnCancel.Click += (s, e) => SelectedOption = ScopeOption.Cancel; - - buttons.Controls.AddRange(new Control[] { btnSelected, btnAll, btnCancel }); - layout.Controls.Add(buttons); - - AcceptButton = btnSelected; - CancelButton = btnCancel; - } -} - -// Usage: -WaitFormVisible = false; -using (var dialog = new ScopeDialog(Selected.Measures.Count(), Model.AllMeasures.Count())) -{ - dialog.ShowDialog(); - switch (dialog.SelectedOption) - { - case ScopeDialog.ScopeOption.OnlySelected: - Selected.Measures.ForEach(m => m.FormatString = "#,##0.00"); - break; - case ScopeDialog.ScopeOption.All: - Model.AllMeasures.ForEach(m => m.FormatString = "#,##0.00"); - break; - case ScopeDialog.ScopeOption.Cancel: - break; - } -} -``` - -### Key rules for scaling-safe dialogs - -- Set `AutoSize = true` and `AutoSizeMode = AutoSizeMode.GrowAndShrink` on the form. -- Use `TableLayoutPanel` (vertical stacking) and `FlowLayoutPanel` (horizontal button rows) instead of manual coordinates. -- Set `FormBorderStyle = FormBorderStyle.FixedDialog` and disable maximize/minimize. -- Set `StartPosition = FormStartPosition.CenterParent`. -- Always set `AcceptButton` and `CancelButton` for keyboard support (Enter/Escape). -- Call `WaitFormVisible = false` before showing a dialog to hide the "Running Macro" spinner. -- Wrap the form in a `using` statement for proper disposal. +For input scenarios beyond what the built-in helpers provide, build custom WinForms dialogs directly in the script. See @how-to-build-custom-winforms-dialogs for patterns covering simple prompts, multi-field forms with validation and reusable dialog classes. ## See also - @script-helper-methods - @script-output-things +- @how-to-build-custom-winforms-dialogs - @csharp-scripts - @script-implement-incremental-refresh - @script-find-replace - @script-convert-dlol-to-import + diff --git a/content/how-tos/scripting-work-with-annotations.md b/content/how-tos/scripting-work-with-annotations.md index 084134b2..6a92cec9 100644 --- a/content/how-tos/scripting-work-with-annotations.md +++ b/content/how-tos/scripting-work-with-annotations.md @@ -60,12 +60,20 @@ var autoGenerated = Model.AllMeasures.Where(m => m.GetAnnotation("GeneratedBy") ## Checking and removing annotations +Use `HasAnnotation()` to gate logic on the presence of a tag: + ```csharp -if (m.HasAnnotation("AUTOGEN")) -{ - Info("This measure was auto-generated."); - m.RemoveAnnotation("AUTOGEN"); -} +// Skip measures that are flagged for manual review +if (m.HasAnnotation("NeedsReview")) return; +``` + +Use `RemoveAnnotation()` to clean up obsolete keys across the model: + +```csharp +// Remove a deprecated annotation key from all measures that still carry it +Model.AllMeasures + .Where(m => m.HasAnnotation("LegacyTag")) + .ForEach(m => m.RemoveAnnotation("LegacyTag")); ``` ## Iterating all annotations on an object diff --git a/content/how-tos/toc.md b/content/how-tos/toc.md index 37f732cd..9f587c26 100644 --- a/content/how-tos/toc.md +++ b/content/how-tos/toc.md @@ -15,6 +15,7 @@ ## [Work with Perspectives and Translations](scripting-perspectives-translations.md) ## [Add, Clone and Remove Objects](scripting-add-clone-remove-objects.md) ## [Use Script UI Helpers](scripting-ui-helpers.md) +## [Build Custom WinForms Dialogs in Scripts](scripting-custom-winforms-dialogs.md) # Model Management and Deployment ## [Deploy Current Model](deploy-current-model.md)