Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/api-reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,12 @@ Annotate an existing schema variable or getter to generate an extension type wra
- Primitives: `Ack.string()`, `Ack.integer()`, `Ack.double()`, `Ack.boolean()`
- Collections: `Ack.list(...)`
- Enums: `Ack.literal()`, `Ack.enumString()`, `Ack.enumValues()`
- Discriminated unions: `Ack.discriminated(...)`

**Unsupported:** `Ack.any()`, `Ack.anyOf()`, `Ack.discriminated()` (use `@AckModel` for discriminated unions)
**Unsupported:** `Ack.any()`, `Ack.anyOf()`

For `Ack.discriminated(...)` constraints with `@AckType`, see
[Type-safe Schemas](../core-concepts/typesafe-schemas.mdx#ackdiscriminated-with-acktype-current-constraints).

**Example:**
```dart
Expand Down
2 changes: 1 addition & 1 deletion docs/core-concepts/json-serialization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ if (result.isOk) {
}
```

**Note**: Nested schemas annotated with `@AckType()` generate their own extension types. When referenced in object fields or list elements, getters return the typed extension type (e.g., `AddressType`) when the reference can be resolved in the same library; unresolved references fall back to `Map<String, Object?>`.
**Note**: Nested schemas annotated with `@AckType()` generate their own extension types. Object/list references must be statically resolvable for typed generation; unsupported or unresolved references fail generation instead of falling back to `Map<String, Object?>`. For current `Ack.discriminated(...)` limits, see [Type-safe Schemas](./typesafe-schemas.mdx#ackdiscriminated-with-acktype-current-constraints).

`@AckType()` is only supported on top-level schema variables/getters, not classes.

Expand Down
13 changes: 11 additions & 2 deletions docs/core-concepts/typesafe-schemas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,18 @@ After running `dart run build_runner build`, the part file contains:
- Primitive schemas (`Ack.string`, `Ack.integer`, `Ack.double`, `Ack.boolean`)
- Lists of supported schemas (`Ack.list(...)`)
- Literal/enum helpers (`Ack.literal`, `Ack.enumValues`, `Ack.enumString`)
- `Ack.discriminated(...)` (with constraints below)

Unsupported helpers include `Ack.any`, `Ack.anyOf`, and `Ack.discriminated`.
Use `@AckModel` for discriminated unions instead.
Unsupported helpers include `Ack.any` and `Ack.anyOf`.

### `Ack.discriminated(...)` with `@AckType` (Current constraints)

- `schemas` must be a non-empty map literal.
- Base schema cannot be nullable.
- Each branch must be a top-level schema variable/getter reference.
- Each branch must be `@AckType`, object-shaped, non-nullable, and declared in the same library.
- Branch discriminator literal (`Ack.literal(...)`) must match the branch key in `schemas`.
- A branch schema can belong to only one discriminated base.

## `@AckType` Resolution Requirements and Limitations

Expand Down
23 changes: 23 additions & 0 deletions example/lib/schema_types_discriminated.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:ack/ack.dart';
import 'package:ack_annotations/ack_annotations.dart';

part 'schema_types_discriminated.g.dart';

/// Discriminated schema example for @AckType extension generation.
@AckType()
final catSchema = Ack.object({
'kind': Ack.literal('cat'),
'lives': Ack.integer(),
});

@AckType()
ObjectSchema get dogSchema => Ack.object({
'kind': Ack.literal('dog'),
'bark': Ack.boolean(),
}).passthrough();

@AckType()
final petSchema = Ack.discriminated(
discriminatorKey: 'kind',
schemas: {'cat': catSchema, 'dog': dogSchema},
);
98 changes: 98 additions & 0 deletions example/lib/schema_types_discriminated.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion packages/ack_generator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## Unreleased

### Features

* **`@AckType` discriminated unions**: Documented current `Ack.discriminated(...)` support and current constraints.

### Bug Fixes

* **Discriminated validation**: `@AckType` now fails generation when `Ack.discriminated(..., schemas: {})` is empty.

## 1.0.0-beta.7

* See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.0-beta.7) for details.
Expand Down Expand Up @@ -56,4 +66,4 @@

## 1.0.0-beta.1 (2025-10-06)

* See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.0-beta.1) for details.
* See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.0-beta.1) for details.
61 changes: 31 additions & 30 deletions packages/ack_generator/lib/src/analyzer/model_analyzer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,10 @@ class ModelAnalyzer {
fields: fields,
additionalProperties: additionalProperties,
additionalPropertiesField: additionalPropertiesField,
// New discriminated properties
discriminatorKey: discriminatedKey,
discriminatorValue: discriminatedValue,
// subtypes will be populated later in a second pass
subtypes: null,
subtypeNames: null,
);
}

Expand Down Expand Up @@ -237,6 +236,12 @@ class ModelAnalyzer {
final subtypes = <ModelInfo>[];

for (final modelInfo in modelInfos) {
if (modelInfo.isFromSchemaVariable) {
// Schema-variable models are linked in a separate pass.
updatedModelInfos.add(modelInfo);
continue;
}

if (modelInfo.isDiscriminatedBase) {
baseClasses.add(modelInfo);
} else if (modelInfo.isDiscriminatedSubtype) {
Expand All @@ -250,7 +255,7 @@ class ModelAnalyzer {
// For each base class, find and validate its subtypes
for (final baseClass in baseClasses) {
final discriminatorKey = baseClass.discriminatorKey!;
final matchingSubtypes = <String, ClassElement2>{};
final matchingSubtypeNames = <String, String>{};

// Find subtypes that belong to this base class
for (final subtype in subtypes) {
Expand All @@ -266,15 +271,15 @@ class ModelAnalyzer {
final discriminatorValue = subtype.discriminatorValue!;

// Validate no duplicate discriminator values
if (matchingSubtypes.containsKey(discriminatorValue)) {
if (matchingSubtypeNames.containsKey(discriminatorValue)) {
throw ArgumentError(
'Duplicate discriminator value "$discriminatorValue" found in '
'${subtype.className} and ${matchingSubtypes[discriminatorValue]!.name3}. '
'${subtype.className} and ${matchingSubtypeNames[discriminatorValue]}. '
'Each discriminator value must be unique within the hierarchy.',
);
}

matchingSubtypes[discriminatorValue] = subtypeElement;
matchingSubtypeNames[discriminatorValue] = subtype.className;

// Validate the discriminator field override
_validateDiscriminatorOverride(
Expand All @@ -295,7 +300,7 @@ class ModelAnalyzer {
additionalPropertiesField: baseClass.additionalPropertiesField,
discriminatorKey: discriminatorKey,
discriminatorValue: null,
subtypes: matchingSubtypes,
subtypeNames: matchingSubtypeNames,
);

updatedModelInfos.add(updatedBaseClass);
Expand All @@ -305,6 +310,7 @@ class ModelAnalyzer {
for (final subtype in subtypes) {
// Find the parent discriminator key for this subtype
String? parentDiscriminatorKey;
String? parentBaseClassName;
for (final baseClass in baseClasses) {
final subtypeElement = elementsByName[subtype.className];
if (subtypeElement == null) {
Expand All @@ -314,6 +320,7 @@ class ModelAnalyzer {
}
if (_isSubtypeOf(subtypeElement, baseClass.className)) {
parentDiscriminatorKey = baseClass.discriminatorKey;
parentBaseClassName = baseClass.className;
break;
}
}
Expand All @@ -329,7 +336,8 @@ class ModelAnalyzer {
discriminatorKey:
parentDiscriminatorKey, // Add parent's discriminator key
discriminatorValue: subtype.discriminatorValue,
subtypes: null,
subtypeNames: null,
discriminatedBaseClassName: parentBaseClassName,
);

updatedModelInfos.add(updatedSubtype);
Expand All @@ -340,20 +348,9 @@ class ModelAnalyzer {

/// Checks if a class extends another class (direct or indirect inheritance)
bool _isSubtypeOf(ClassElement2 element, String baseClassName) {
// Check direct superclass
final supertype = element.supertype;
if (supertype?.element3.name3 == baseClassName) {
return true;
}

// Check indirect inheritance through supertypes
for (final supertype in element.allSupertypes) {
if (supertype.element3.name3 == baseClassName) {
return true;
}
}

return false;
return element.allSupertypes.any(
(supertype) => supertype.element3.name3 == baseClassName,
);
}

/// Validates that the discriminator field or getter is properly overridden in subtype
Expand All @@ -363,10 +360,12 @@ class ModelAnalyzer {
String expectedValue,
) {
// First check for field override
final discriminatorField = element.fields2.cast<FieldElement2?>().firstWhere(
(field) => field?.name3 == discriminatorKey,
orElse: () => null,
);
final discriminatorField = element.fields2
.cast<FieldElement2?>()
.firstWhere(
(field) => field?.name3 == discriminatorKey,
orElse: () => null,
);

if (discriminatorField != null) {
// For fields, we can't easily validate the returned value at compile time
Expand All @@ -383,10 +382,12 @@ class ModelAnalyzer {
}

// Check for getter override
final discriminatorGetter = element.getters2.cast<GetterElement?>().firstWhere(
(getter) => getter?.name3 == discriminatorKey,
orElse: () => null,
);
final discriminatorGetter = element.getters2
.cast<GetterElement?>()
.firstWhere(
(getter) => getter?.name3 == discriminatorKey,
orElse: () => null,
);

if (discriminatorGetter != null) {
// For getters, validate return type
Expand Down
Loading
Loading