diff --git a/CONFIGURATION_RESOLUTION.md b/CONFIGURATION_RESOLUTION.md new file mode 100644 index 0000000..a7f4efe --- /dev/null +++ b/CONFIGURATION_RESOLUTION.md @@ -0,0 +1,106 @@ +# Configuration Resolution: Scope-First vs. Type-First + +## The Two Strategies + +Given `Foo extends Entity` with configurations at four points in the matrix: + +| | Instance | Global | +|---|---|---| +| **Foo** | I·Foo | G·Foo | +| **Entity** | I·Entity | G·Entity | + +**Scope-first** exhausts all instance settings before looking at global ones: +``` +I·Foo → I·Entity → G·Foo → G·Entity → Default +``` + +**Type-first** exhausts all Foo-specific settings before looking at Entity: +``` +I·Foo → G·Foo → I·Entity → G·Entity → Default +``` + +Both agree on `I·Foo` being first and `G·Entity` being last. The dispute is the middle two. + +--- + +## Where Scope-First Makes Sense + +**Setting: `textAlign` or `nullRepresentation`** + +A developer creates a reporting grid and writes: +```java +easyGrid.configuration.forType(Entity.class).setTextAlign(RIGHT); +``` +The intent is *"every Entity-like column in this grid should be right-aligned."* Since `Foo IS-AN Entity`, Foo columns should also be right-aligned. Instance context wins: the developer explicitly opted every Entity column into this layout decision, and Foo, being substitutable for Entity, should inherit it. + +If type-first were applied, `G·Foo` would sit between `I·Foo` and `I·Entity`. An unrelated global renderer for Foo would silently block the instance-level alignment — which is the opposite of what the developer intended. + +--- + +## Where Type-First Makes Sense + +**Setting: `rendererFactory`** + +Globally, `Foo` has a specialized renderer registered: +```java +GlobalEasyGridConfiguration.forType(Foo.class) + .setRendererFactory(FooRenderers.of(...)); +``` +Independently, an instance configures Entity columns with a generic formatter: +```java +easyGrid.configuration.forType(Entity.class).setFormatter(e -> e.getId().toString()); +``` + +Under scope-first, `I·Entity` wins over `G·Foo`. The Foo-specific renderer — which encodes *what a Foo looks like*, arguably a type invariant — gets silently replaced by a generic Entity formatter that the developer may not have intended to apply to Foo. + +Type-first preserves the type contract: `G·Foo` sits above `I·Entity`, so Foo's rendering semantics are honoured even across scope boundaries. + +--- + +## Liskov Substitution Principle + +LSP says: a `Foo` must be usable wherever an `Entity` is expected, without callers needing to know the difference. + +Applied to configuration, it has two implications that pull in opposite directions: + +**LSP supports scope-first for behavioural settings.** If you configure `Entity` at instance level — null representation, alignment, a locale-specific formatter — you are specifying how *this grid* handles all Entity values. A `Foo` substituting for `Entity` should satisfy those same observable postconditions. Refusing to inherit `I·Entity` because Foo has a `G·Foo` entry would mean the grid behaves differently for `Foo` vs `Entity` in ways the calling code did not anticipate. + +**LSP supports type-first for type-defining settings.** LSP also requires that subtypes honour their own invariants. If `Foo` has a type-level rendering contract — *this is how a Foo is displayed* — then substituting a generic `Entity` renderer for it violates the Foo invariant. The renderer is not just a postcondition on the grid; it is a property of `Foo` itself. + +The tension resolves in favour of **scope-first for all properties**. The type-first argument rests on the assumption that `I·Entity` displaces `G·Foo` unintentionally — but instance configuration is always deliberate. A developer who writes `forType(Entity.class).setFormatter(...)` knows `Foo IS-AN Entity`; if they wanted Foo to keep its global renderer they would have set `I·Foo` separately. The argument collapses entirely at broad overrides such as `forType(Object.class).setFormatter(...)`, where the developer has unambiguously stated that everything in this grid uses this formatter and global type-specific registrations cannot claim precedence. **Global registrations are defaults. Instance registrations are decisions. Decisions outrank defaults regardless of type specificity.** + +--- + +## Current Implementation + +The implementation uses **scope-first** for all properties. For a `Foo extends Entity` column the effective chain is: + +``` +per-column → I·Foo → I·Entity → I·Object → G·Foo → G·Entity → G·Object → Default +``` + +This is built in two pieces: + +**Instance chain** — `EasyGridConfigurationClassMap.getOrCreate(Foo)` walks the Java class hierarchy within the same map, producing a `ColumnConfigurationImpl` chain: + +``` +I·Foo(impl) → I·Entity(impl) → I·Object(impl) +``` + +**Global chain** — `GlobalEasyGridConfiguration.resolve(Foo)` similarly produces: + +``` +G·Foo(impl) → G·Entity(impl) → G·Object(impl) +``` + +**Bridge** — `InstanceEasyGridConfiguration.forType(Foo)` wraps both into a `ColumnConfigurationLink`: + +``` +ColumnConfigurationLink(primary = I·Foo chain, fallback = G·Foo chain) +``` + +`ColumnConfigurationLink.get()` consults the primary chain first and only falls back to the global chain when the primary returns `null` for a given property. + +**Per-column** — `InstanceEasyGridConfiguration.resolve(Foo)` wraps the link in a fresh `ColumnConfigurationImpl` whose fields are overridden by column-level setters (`setNullRepresentation`, `setFormatter`, `setRendererFactory`). + +**Default** — when the entire chain returns `null` for `getRendererFactory()`, `EasyColumn.createRenderer` applies a `ColumnConfigurationTextRenderer` with null-representation support as the last resort. diff --git a/SPECIFICATIONS.md b/SPECIFICATIONS.md index 688c193..2b5e683 100644 --- a/SPECIFICATIONS.md +++ b/SPECIFICATIONS.md @@ -27,10 +27,14 @@ Each Java type maps to a default column renderer and configuration: | `LocalDate` | `LocalDateRenderer` | Start | Chronological | | `LocalDateTime` | `LocalDateTimeRenderer` | Start | Chronological | | `LocalTime` | `TextRenderer` (formatted) | Start | Chronological | -| `Enum` | `TextRenderer` (name) | Start | Alphabetical | +| `Enum` | `TextRenderer` (`toString()`) | Start | Alphabetical | Custom type mappings can be registered globally or per-grid instance. +**Boolean rendering:** `Boolean` columns render as the string literals `"true"` or `"false"` by default. Any other representation ("Yes"/"No", "On"/"Off", localised strings, etc.) requires a custom formatter — typically one that delegates to an application i18n provider. + +**Enum rendering:** Enum columns render using `Enum.toString()`, not `Enum.name()`. Per the Java documentation for `Enum.name()`: *"Most programmers should use the toString method in preference to this one, as the toString method may return a more user-friendly name."* Enums that need human-readable labels should override `toString()` accordingly. + ### 2.3 Sorting All columns backed by `Comparable` property types are sortable by default. Multi-column sorting is supported. @@ -139,22 +143,83 @@ easyGrid.getColumn("firstName") See [FEATURE_ROW_ACTIONS.md](FEATURE_ROW_ACTIONS.md). -### 3.5 Global Type Configuration +### 3.5 Type Configuration Tree + +Column display configuration is resolved through a three-level tree, from most to least specific: + +| Level | API | Scope | +|---|---|---| +| **Column** | `EasyColumn` setters | One specific column | +| **Instance** | `EasyGrid.forType(Class)` | All columns of that type in one grid | +| **Global** | `GlobalEasyGridConfiguration.forType(Class)` | All grids in the application | + +Within each level the class hierarchy is walked before the tree falls through to the next level (scope-first). The full resolution order for a `Foo extends Entity` column is: + +``` +Column·Foo + → Instance·Foo → Instance·Entity → Instance·Object + → Global·Foo → Global·Entity → Global·Object + → Built-in default +``` + +The first non-`null` value found wins. See [CONFIGURATION_RESOLUTION.md](CONFIGURATION_RESOLUTION.md) for the rationale behind scope-first ordering. + +**Column level** — `EasyGrid.addColumn(…)` returns an `EasyColumn` whose setters write into an isolated configuration node at the top of the chain for that column only: + +```java +easyGrid.addColumn("active").setNullRepresentation("—"); +easyGrid.addColumn("salary").setTextAlign(ColumnTextAlign.END); +``` + +**Instance level** — `EasyGrid.forType(Class)` returns the instance-level `ColumnConfiguration` for a type. Changes apply to every column of that type on this grid: + +```java +easyGrid.forType(BigDecimal.class) + .setRendererFactory(NumberRenderers.of("%,.2f", Locale.US)); +``` + +**Global level** — `GlobalEasyGridConfiguration.forType(Class)` returns the application-wide `ColumnConfiguration`. Call `GlobalEasyGridConfiguration.freeze()` after startup to prevent further modifications: + +```java +GlobalEasyGridConfiguration.forType(LocalDate.class) + .setRendererFactory(LocalDateRenderers.of("dd/MM/yyyy")); +GlobalEasyGridConfiguration.freeze(); +``` + +#### Null Representation -Register custom column configurations that apply to all `EasyGrid` instances: +The `nullRepresentation` property controls what is displayed when a column value is `null`. The built-in global default registers `""` (empty string) on `Object.class`, so every column starts with an empty cell for `null` values. Override it at any level: ```java -// Register a global formatter for a custom type -EasyGrid.registerTypeConfig(Money.class, config -> { - config.setFormatter(money -> money.getCurrency() + " " + money.getAmount()); -}); - -// Register a global renderer for nested types -EasyGrid.registerTypeConfig(Address.class, config -> { - config.setFormatter(address -> - address.getStreet() + ", " + address.getCity() - ); -}); +// All columns in this grid show "–" for null +easyGrid.forType(Object.class).setNullRepresentation("–"); + +// Only the "email" column shows "(none)" for null +easyGrid.addColumn("email").setNullRepresentation("(none)"); +``` + +Formatters that receive a `ColumnConfiguration` parameter can call `getNullRepresentation()` to produce consistent output. + +#### Type Hierarchy Support + +Inside each scope level, `EasyGridConfigurationClassMap` walks the Java class hierarchy. When a configuration is requested for `Foo` and none exists, it is created with `Foo`'s superclass configuration as its parent, continuing up to `Object`. Primitive types are mapped to their boxed counterparts before hierarchy walking (`int` → `Integer`, `boolean` → `Boolean`, etc.). + +A global `Number.class` renderer factory is therefore automatically inherited by `Integer`, `Long`, `BigDecimal`, and every other `Number` subtype unless a more specific configuration overrides it. + +#### Renderer Utility Classes + +Three `@UtilityClass` types in `com.flowingcode.vaadin.addons.easygrid.renderers` produce `RendererFactory` instances for common value types: + +- **`LocalDateRenderers`** — wraps `LocalDateRenderer`; overloads accept a format pattern, locale, null representation, or a `DateTimeFormatter` supplier. +- **`LocalDateTimeRenderers`** — wraps `LocalDateTimeRenderer`; same overloads as `LocalDateRenderers`. +- **`NumberRenderers`** — wraps `NumberRenderer`; overloads accept a `NumberFormat`, a `Locale`, or a `Formatter` pattern string with optional locale and null representation. + +```java +GlobalEasyGridConfiguration.forType(LocalDate.class) + .setRendererFactory(LocalDateRenderers.of("dd/MM/yyyy", Locale.UK)); + +GlobalEasyGridConfiguration.forType(Number.class) + .setRendererFactory(NumberRenderers.of(NumberFormat.getInstance())); ``` ## 4. Default Header Generation