Schema Definition¶
All schema configuration is done via annotations from phorm_annotations. The phorm_generator reads these annotations and generates the SQL schema, mixins, and serialization helpers.
@Schema¶
Defines table-level configuration for a class.
@Schema(
tableName: 'users', // Optional. Defaults to class name in snakeCase
paranoid: true, // Enable soft deletes (requires deleted_at column)
timestamps: true, // Auto-inject created_at / updated_at (default: true)
columnNaming: ColumnNamingStrategy.snakeCase, // default
dialect: SqlDialectKind.sqlite, // Target DDL dialect (default: sqlite)
indexes: [
Index(columns: ['email'], unique: true),
Index(columns: ['first_name', 'last_name']),
],
relationships: [
HasMany(model: Post, foreignKey: 'user_id'),
HasOne(model: Profile, foreignKey: 'user_id'),
],
useToJson: true, // Generate _$PhormClassToJson() (default: true)
useFromJson: true, // Generate _$PhormClassFromJson() (default: true)
useCopyWith: true, // Generate copyWith() (default: true)
generateFullService: true, // Generate the pluralized service class, e.g. Users (default: true)
)
class User extends Model with _$PhormUserMixin { ... }
@Schema Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
tableName |
String? |
class name | Explicit SQL table name |
paranoid |
bool |
false |
Soft delete support |
timestamps |
bool |
true |
Auto created_at/updated_at |
columnNaming |
ColumnNamingStrategy |
snakeCase |
Field → column mapping strategy |
dialect |
SqlDialectKind |
sqlite |
Target SQL dialect for DDL generation (sqlite, postgres, mysql) |
indexes |
List<Index> |
[] |
Table indexes |
relationships |
List<Relationship> |
[] |
HasMany, HasOne, BelongsTo/Join, ManyToMany (see note below) |
useToJson |
bool |
true |
Generate toJson mixin |
useFromJson |
bool |
true |
Generate fromJson helper |
useCopyWith |
bool |
true |
Generate copyWith method |
useValidator |
bool |
true |
Generate validate() method |
useToString |
bool |
true |
Generate toString() helper |
generateFullService |
bool |
true |
Generate the pluralized static service class (e.g. Users) exposing the full CRUD/query API (insert, readAll, where, watchAll, column constants, …). Set false to keep only the lightweight artefacts (schema, table, fromJson/toJson, copyWith). |
Note
Relationships are declarations used for querying and eager loading — the
generator does not create the related tables. The one exception is
ManyToMany(createPivot: true), which appends a
CREATE TABLE IF NOT EXISTS <pivot> to the generated schema so the join table
is created automatically (optionally with ON DELETE CASCADE foreign keys via
pivotForeignKeys: true). See the
Many-to-Many
guide for details.
Lightweight models (generateFullService: false)¶
By default the generator emits a pluralized static service (e.g. Users)
with the full CRUD/query API and typed column constants — that's what lets you
write Users.readAll() or Users.where(Users.email.eq(...)).
Set generateFullService: false when you don't want that facade — e.g. you wrap
data access in your own repository, use dependency injection, or just want less
generated code. You still get the schema, the Table instance (usersTable),
fromJson/toJson, copyWith and the mixin; only the Users service class and
its column constants are skipped.
Do CRUD by resolving a PhormCore<T> from the database instead:
@Schema(tableName: 'users', generateFullService: false)
class User extends Model with _$PhormUserMixin { /* ... */ }
// 1. Register the generated table on the DB.
final db = DB(databaseName: 'app.db', version: 1, tables: [usersTable]);
// 2. Resolve a service for the model (or build PhormCore directly).
final users = db.service<User>();
// final users = PhormCore<User>(dbManager: db, table: usersTable);
// 3. CRUD is identical to the generated facade.
await users.insert(User(id: 1, email: 'a@b.c'));
final one = await users.readOne(1);
final page = await users.readAll(limit: 20);
// 4. Without generated column constants, reference columns via a typed
// PhormColumn, or a raw string in a WhereBuilder.
const email = PhormColumn<String>('email');
final admins = await users.where(email.like('%@admin.com')).get();
// or: await users.readAll(where: WhereBuilder().eq('email', 'a@b.c'));
Note
db.service<T>() requires the model's Table to be registered in
DB(tables: [...]) — otherwise it throws a StateError. Building
PhormCore<T>(dbManager: db, table: usersTable) yourself has no such
requirement.
Target SQL Dialect¶
dialect tells the generator which database flavour to emit DDL for. It defaults
to SqlDialectKind.sqlite, so existing schemas are unaffected. SQLite is fully
implemented; postgres and mysql are scaffolded (their type mapping is in
place, with remaining DDL specifics tracked as TODOs in phorm_generator).
Column Naming Strategies¶
| Strategy | Dart field | SQL column |
|---|---|---|
snakeCase (default) |
firstName |
first_name |
camelCase |
firstName |
firstName |
pascalCase |
firstName |
FirstName |
@ID¶
Marks a field as the primary key. Always NOT NULL.
@ID(autoIncrement: false)
@override
final String id;
// Integer auto-increment PK (sqlType inferred as INTEGER)
@ID(autoIncrement: true)
@override
final int id;
// Custom primary key column name (runtime primaryKey: 'user_uid')
@ID(columnName: 'user_uid')
@override
final String uid;
Automatic Primary Key Resolution¶
The phorm_generator automatically identifies the primary key of your model by looking for the @ID annotation.
- SQL Schema: It adds the
PRIMARY KEYconstraint to the corresponding column in the generatedCREATE TABLEstatement. - Table Configuration: It automatically injects the
primaryKeycolumn name into the generatedTableinstance (e.g.,primaryKey: 'user_uid'). - Runtime Support: The
PhormCoreengine usestable.primaryKeyto perform ID-based lookups (readOne,delete, etc.), ensuring that custom primary key names work seamlessly. - Relationship Resolution: Other models referencing this model via
BelongsToorManyToManywill automatically use this primary key name for foreign key serialization.
Note
If no field is annotated with @ID, the generator will default to id, but it is highly recommended to explicitly annotate your primary key field.
@ID Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
sqlType |
String? |
inferred | Explicit SQLite type override |
autoIncrement |
bool |
false |
Auto-increment (for int PK) |
unique |
bool |
true |
Enforce uniqueness |
columnName |
String? |
null |
Override column name |
collate |
String? |
null |
SQLite collation (NOCASE, etc.) |
Warning
autoIncrement: true only works with int fields (mapped to INTEGER). For string UUIDs, use autoIncrement: false (default).
@Column¶
Defines a regular column.
@Column()
final String firstName;
@Column(unique: true)
final String email;
@Column()
final int? age;
@Column(defaultValue: true)
final bool isActive;
@Column(
validators: [
ContainsValidator(['M', 'F', 'Other'], constraint: 'gender_check'),
],
)
final String gender;
// Explicit column name (overrides naming strategy)
@Column(columnName: 'user_city')
final String city;
// Explicit SQL type override (raw string)
@Column(sqlType: 'VARCHAR(255)')
final String bio;
// Typed SQL type override (SqlType object)
@Column(type: VARCHAR(255))
final String title;
| Parameter | Type | Default | Description |
|---|---|---|---|
sqlType |
String? |
inferred | Explicit SQL type override as a raw string |
type |
SqlType? |
inferred | Explicit SQL type as a typed object (e.g. VARCHAR(255), DECIMAL(10, 2), JSONB()) |
columnName |
String? |
null |
Override column name |
unique |
bool |
false |
UNIQUE constraint |
defaultValue |
dynamic |
null |
SQL DEFAULT value |
validators |
List<IValidator>? |
null |
Value constraints (Check/Regex/etc.) |
converter |
ValueConverter? |
null |
Custom type transformer |
collate |
String? |
null |
SQLite collation (NOCASE, etc.) |
Data Types¶
PHORM maps your Dart field types to SQL column types automatically. In the overwhelming majority of cases you write nothing — declare the field and let the generator infer the type. When you do need control, there is one decision to make:
| You want… | Use | Example |
|---|---|---|
| The default type for your Dart type | (nothing — inferred) | final int age; |
| A specific, typed SQL type | @Column(type: ...) |
@Column(type: VARCHAR(255)) |
Exotic DDL that has no SqlType class |
@Column(sqlType: '...') |
@Column(sqlType: 'INTEGER CHECK (age >= 0)') |
Store a complex object (Map, List, enum, domain type) |
@Column(converter: ...) |
see Value Converters |
Tip
Prefer type: (a typed SqlType object) over sqlType: (a raw string)
whenever a matching SqlType exists — it is checked at compile time and reads
more clearly. Reach for sqlType: only for DDL that no SqlType covers.
1. Inferred (the default)¶
| Dart Type | SQLite Type | Notes |
|---|---|---|
String |
TEXT |
Default for strings, UUIDs |
int |
INTEGER |
Standard integer |
bool |
INTEGER |
Stored as 1 (true) / 0 (false) |
double |
REAL |
Floating point numbers |
num |
NUMERIC |
Supports both int and double |
DateTime |
TEXT |
Stored as ISO-8601 strings |
Uint8List |
BLOB |
Binary data |
Booleans and dates are converted between their Dart and SQLite representations
automatically — you never handle 1/0 or ISO strings yourself.
2. Typed override — type: (recommended)¶
Pass a typed SqlType object. Compile-time checked, and clearer for
parameterized types:
@Column(type: VARCHAR(255))
final String title;
@Column(type: DECIMAL(10, 2))
final double price;
@Column(type: JSONB()) // Postgres
final Map<String, dynamic> metadata;
SqlType classes are organised by dialect (all exported from
phorm_annotations):
| File | Types |
|---|---|
sql_types/common_types |
VARCHAR(length), TEXT, INTEGER, BIGINT, BOOLEAN, REAL, DOUBLE, DECIMAL(p, s), DATE, TIME, TIMESTAMP, BLOB, JSON |
sql_types/sqlite_types |
NUMERIC, Collate |
sql_types/postgres_types |
JSONB |
sql_types/mysql_types |
(MySQL-only types — scaffolded) |
Note
On SQLite, types like BOOLEAN, DATE, TIME, TIMESTAMP and JSON are
stored via SQLite's type affinity (there are only 5 storage classes:
TEXT, INTEGER, REAL, BLOB, NUMERIC). They are most meaningful when
targeting postgres/mysql.
3. Raw override — sqlType:¶
An escape hatch for DDL that no SqlType class expresses. It is emitted into the
CREATE TABLE verbatim:
4. Complex objects — converter:¶
To store a Map, List, enum, or a custom/third-party class, do not invent
an sqlType — use a Value Converter. The converter decides
the storage type (usually TEXT via jsonEncode) and handles both directions.
SqlType vs SqlTypes¶
SqlType— the typed hierarchy you pass totype:(TEXT(),VARCHAR(255),JSONB(), …). This is what you want.SqlTypes— an older class of plain string constants (SqlTypes.text). It is deprecated; usetype:(typed) or a rawsqlType:string instead.
Note
Type resolution precedence in the generator: sqlType (raw string) →
type (SqlType object) → converter's SQL type → inferred from the Dart
field type. The first one provided wins.
String Collations (NOCASE, BINARY)¶
SQLite allows you to specify how strings are compared using the COLLATE clause. This is especially useful for case-insensitive searching or sorting.
BINARY(default): Case-sensitive comparison.'Alice' != 'alice'.NOCASE: Case-insensitive comparison (for ASCII characters).'Alice' == 'alice'.
You can apply these using the collate property:
@Column(collate: Collate.noCase)
final String email;
@Column(collate: Collate.binary) // Explicit binary
final String password;
Value Converters¶
Value Converters allow you to transform complex Dart types into simple types supported by SQLite (and vice versa). This is useful for storing objects like Map, List, Enums, or custom domain objects as TEXT, INTEGER, or BLOB in the database.
Why use Value Converters?¶
- Support for any data type: Store complex objects (Colors, Points, custom classes) in standard SQL columns.
- Encapsulation: Keep transformation logic (like
jsonEncode/jsonDecode) in one place instead of scattering it throughout your UI or service layers. - Type Safety: Work with strongly-typed objects in your Dart code while the converter handles the low-level SQL representation.
- Automatic Integration: PHORM automatically uses converters in
toJson(),fromJson(), and database operations.
Creating a Converter¶
To create a converter, inherit from ValueConverter<DartType, SqlType> and implement fromSql and toSql.
class JsonMapConverter extends ValueConverter<Map<String, dynamic>, String> {
const JsonMapConverter();
@override
Map<String, dynamic> fromSql(String sqlValue) {
return jsonDecode(sqlValue) as Map<String, dynamic>;
}
@override
String toSql(Map<String, dynamic> value) {
return jsonEncode(value);
}
}
Example: Storing an Enum¶
Instead of manually converting Enums to strings everywhere, use a converter:
enum UserRole { admin, editor, user }
class RoleConverter extends ValueConverter<UserRole, String> {
const RoleConverter();
@override
UserRole fromSql(String sqlValue) =>
UserRole.values.firstWhere((e) => e.name == sqlValue);
@override
String toSql(UserRole value) => value.name;
}
// In your model:
@Column(converter: RoleConverter())
final UserRole role;
Using a Converter¶
Apply the converter to a field using the converter parameter in @Column.
How it Works¶
- To Database: When you save a model or call
toJson(), PHORM callsconverter.toSql(). - From Database: When you read a model or call
fromJson(), PHORM callsconverter.fromSql().
Important
The converter must have a const constructor so it can be used inside the @Column annotation.
Validators¶
Validators allow you to enforce data integrity both in SQLite (via CHECK constraints) and in Dart (via toJson() validation).
@Column(
validators: [
ContainsValidator(['active', 'inactive', 'pending']),
NotEmptyValidator(),
],
)
final String status;
For a full list of available validators and details on how they work, see the Validators documentation.
Indexes¶
Indexes dramatically speed up query performance on frequently filtered columns.
@Schema(
tableName: 'users',
indexes: [
Index(columns: ['email'], unique: true), // Unique index
Index(columns: ['first_name', 'last_name']), // Composite index
Index(columns: ['city']), // Simple index
],
)
| Parameter | Type | Description |
|---|---|---|
columns |
List<String> |
Columns included in the index |
unique |
bool |
Enforces uniqueness across rows |
Important
Always add an index on foreign key columns (user_id, etc.). Without them, JOIN operations scan the entire table and degrade performance at scale.
Complete Model Example¶
import 'package:phorm_annotations/phorm_annotations.dart';
part 'user.sql.g.dart';
@Schema(
tableName: 'users',
paranoid: true,
columnNaming: ColumnNamingStrategy.snakeCase,
indexes: [
Index(columns: ['email'], unique: true),
Index(columns: ['first_name', 'last_name']),
],
relationships: [
HasMany(model: Order, foreignKey: 'user_id'),
],
)
class User extends Model with _$PhormUserMixin {
@ID()
@override
final String id;
@Column()
final String firstName;
@Column()
final String lastName;
@Column(unique: true)
final String email;
@Column()
final String phone;
@Column()
final String? birthDate;
@Column()
final int? age;
@Column(
validators: [
ContainsValidator(['M', 'F', 'Other'], constraint: 'gender_check')
],
)
final String gender;
@Column()
final String city;
@Column()
final String country;
@Column()
final String? address;
@Column(defaultValue: true)
final bool isActive;
@Column(defaultValue: false)
final bool isVerified;
User({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
required this.phone,
this.birthDate,
this.age,
required this.gender,
required this.city,
required this.country,
this.address,
this.isActive = true,
this.isVerified = false,
});
String get fullName => '$firstName $lastName';
factory User.fromJson(Map<String, dynamic> json) => _$PhormUserFromJson(json);
}