SchemaModel Class API

This document describes the SchemaModel API in Ack, which provides a way to create schema-based models with automatic validation. While you can implement SchemaModel classes manually, the recommended approach is to use code generation.

Overview

The SchemaModel class is a base class for creating schema-based models that can validate data against a schema. It provides a simple and intuitive API for creating, validating, and accessing typed data.

Key Features

  • Automatic Validation: Validation happens automatically when a SchemaModel is created
  • Simple Error Handling: Easy access to validation errors through isValid and getErrors()
  • Type Safety: Uses Object? instead of dynamic for better type safety
  • Code Generation: Generate schema models automatically from annotated classes
  • Direct Property Access: Access validated data directly through typed getters

Using Code Generation (Recommended Approach)

The easiest way to use SchemaModel is with code generation. This approach lets you define your models as regular Dart classes with annotations, and the generator creates the corresponding SchemaModel classes for you.

1. Define Your Model Class

// file: user.dart
import 'package:ack/ack.dart';

@Schema()
class User {
  @MinLength(2)
  final String name;

  @IsEmail()
  final String email;

  @Min(0)
  final int? age;

  User({required this.name, required this.email, this.age});
}

2. Generate the Schema Class

Run the build_runner command to generate the schema class:

dart run build_runner build --delete-conflicting-outputs

This generates a user.schema.dart file with a UserSchema class that extends SchemaModel.

3. Use the Generated Schema Class

There are several ways to use the generated schema class:

Approach 1: Direct Property Access

// file: main.dart
import 'user.dart';
import 'user.schema.dart'; // Import the generated file

void main() {
  final userData = {
    'name': 'John Doe',
    'email': 'john@example.com',
    'age': 30,
  };

  // Create schema instance - validation happens with parse method
  final userSchema = UserSchema().parse(userData);

  // Check if the data is valid
  if (userSchema.isValid) {
    // Access properties directly from the schema
    print('Valid User: ${userSchema.name}, ${userSchema.email}, Age: ${userSchema.age}');

    // Create a User model if needed
    final user = User(
      name: userSchema.name,
      email: userSchema.email,
      age: userSchema.age,
    );
  }
}

Approach 2: Factory Method Pattern

// file: user.dart
class User {
  final String name;
  final String email;
  final int? age;
  
  User({required this.name, required this.email, this.age});
  
  // Add a factory method to create from schema
  factory User.fromSchema(UserSchema schema) {
    if (!schema.isValid) {
      throw AckException(schema.getErrors()!);
    }
    
    return User(
      name: schema.name,
      email: schema.email,
      age: schema.age,
    );
  }
}

// Usage
void main() {
  final userSchema = UserSchema().parse(userData);
  final user = User.fromSchema(userSchema); // Throws if invalid
}

Approach 3: Extension Methods

// file: user_extensions.dart
extension UserSchemaExtensions on UserSchema {
  User toUser() {
    if (!isValid) {
      throw AckException(getErrors()!);
    }
    
    return User(
      name: name,
      email: email,
      age: age,
    );
  }
}

// Usage
void main() {
  final userSchema = UserSchema().parse(userData);
  if (userSchema.isValid) {
    final user = userSchema.toUser();
  }
}

Generated Schema Class API

When you use code generation, the generated schema class extends the base SchemaModel class and adds typed property getters, static methods and properties for convenience. Here's what you'll find in a generated schema class:

Static Properties and Methods

ObjectSchema get definition

Returns the ObjectSchema instance that defines the validation rules for the model, generated from your annotations.

final userSchema = UserSchema().definition;
print('Schema: ${userSchema}');

Map<String, Object?> toJsonSchema()

Converts the schema to a JSON Schema as a Map.

final jsonSchema = UserSchema().toJsonSchema();
print('JSON Schema: $jsonSchema');

// Convert to JSON string if needed
final jsonString = jsonEncode(jsonSchema);
print('JSON String:\n$jsonString');

Instance Properties

The generated schema provides typed getters for each property in your model:

final schema = UserSchema().parse(userData);
if (schema.isValid) {
  String name = schema.name;        // Non-nullable property
  String email = schema.email;      // Non-nullable property
  int? age = schema.age;            // Nullable property
}

Instance Methods

Map<String, dynamic> toMap()

Converts the schema instance to a map, useful for serialization.

final schema = UserSchema().parse(userData);
if (schema.isValid) {
  final map = schema.toMap();
  final json = jsonEncode(map);
  print('JSON: $json');
}

bool get isValid

Returns whether the data is valid according to the schema.

final schema = UserSchema().parse(userData);
if (schema.isValid) {
  // Use schema...
}

SchemaError? getErrors()

Returns the validation errors if the data is invalid, or null if the data is valid.

final schema = UserSchema().parse(userData);
if (!schema.isValid) {
  print('Errors: ${schema.getErrors()}');
}

Working with Nested Models

The code generator automatically handles nested models if the nested type is also annotated with @Schema.

// file: address.dart
import 'package:ack/ack.dart';

@Schema()
class Address {
  final String street;
  final String city;

  Address({required this.street, required this.city});
  
  factory Address.fromSchema(AddressSchema schema) {
    return Address(
      street: schema.street,
      city: schema.city,
    );
  }
}

// file: user.dart
import 'package:ack/ack.dart';
import 'address.dart';

@Schema()
class User {
  final String name;
  final Address address;

  User({required this.name, required this.address});
  
  factory User.fromSchema(UserSchema schema) {
    return User(
      name: schema.name,
      address: Address.fromSchema(schema.address),
    );
  }
}

When you run build_runner, it generates both AddressSchema and UserSchema classes. The UserSchema will automatically use AddressSchema for validating the nested address field.

final userData = {
  'name': 'John Doe',
  'address': {
    'street': '123 Main St',
    'city': 'Anytown',
  },
};

// Create schema instance - validation happens automatically
final userSchema = UserSchema(userData);

// Check if valid and access properties
if (userSchema.isValid) {
  print('User: ${userSchema.name}, City: ${userSchema.address.city}');
  
  // Create model if needed
  final user = User.fromSchema(userSchema);
}

Manual Implementation (Advanced)

While code generation is recommended, you can also implement SchemaModel manually for more control. The manual implementation follows the same pattern as the base SchemaModel class, with validation happening automatically in the constructor.

class UserSchema extends SchemaModel {
  UserSchema(Object? data) : super(data);

  @override
  AckSchema getSchema() {
    return Ack.object(
      {
        'name': Ack.string.minLength(2),
        'email': Ack.string.email(),
        'age': Ack.int.min(0).nullable(),
      },
      required: ['name', 'email'],
    );
  }
  
  // Add typed getters
  String get name => getValue<String>('name')!;
  String get email => getValue<String>('email')!;
  int? get age => getValue<int?>('age');
}

SchemaRegistry

The SchemaRegistry is an optional utility for managing SchemaModel factories. It allows you to create SchemaModel instances dynamically based on a schema type.

// Register a factory for creating UserSchema instances
SchemaRegistry.register<UserSchema>((data) => UserSchema(data));

// Create a UserSchema instance using the registered factory
final schema = SchemaRegistry.createSchema<UserSchema>(userData);

if (schema != null && schema.isValid) {
  print('Name: ${schema.name}');
  
  // Create model using your preferred pattern
  final user = User(name: schema.name, email: schema.email);
}

Note: Using SchemaRegistry is optional and useful in scenarios where you need to handle schema creation dynamically.

Migration from v1.x

If you're upgrading from v1.x where schemas had a toModel() method, see the migration patterns shown above. The key change is that schemas no longer create model instances - they only validate and provide typed access to data. This gives you complete control over how you create your model instances.