Values and codecs topic

Domain types and storage

Record fields keep their Dart types throughout generated creation, patches, predicates, projections, relationships and cursor values. A codec describes the storage boundary: encode produces a driver value, and decode validates and constructs the application value.

Custom IDs and values

Declare a public constant codec beside its type:

import 'package:orm/schema.dart';

extension type const UserId(int value) {
  static const codec = Codec<UserId>.integer(_decode, _encode);
  static UserId _decode(Object? value) => UserId(value as int);
  static int _encode(UserId value) => value.value;
}

final user = model('users', (
  id: custom(UserId.codec).identity(),
  name: text(),
));

After generation, db.user.byId(UserId(1)) accepts the domain ID; db.user.byId(1) is a static error. Use the same domain ID codec on foreign-key fields. Required integer primary keys can retain database-generated identities.

Codec<T>.text(decode, encode) and Codec<T>.integer(decode, encode) constrain the encoder's return type. The general Codec<T>(storage, decode, encode) accepts the storage tags in the table below. It is the application's responsibility to validate the representation and keep encoding deterministic.

custom(codec) accepts a public const variable or public static const field. The generator checks the reference's resolved Dart type and reads the constant storage tag. It never calls application encoders, decoders or model constructors. Private codec references and inline codec constructors are rejected. Use custom(codec).nullable() for optional values: SQL NULL bypasses a non-nullable domain decoder. A codec whose Dart value type is nullable also produces a nullable column.

Imported types, extension types, public record aliases and nested generic types retain qualified names in generated code. Two different libraries may both declare Email. Output can be generated into a different directory.

Codec.map remains available when constructing table definitions directly.

Enums

enum Membership { pending, active, cancelled }

final account = model('accounts', (
  id: identity(),
  membership: enumeration(Membership.values, labels: {
    Membership.pending: 'pending-payment',
    Membership.active: 'active',
    Membership.cancelled: 'closed',
  }),
  previousMembership: enumeration(Membership.values).nullable(),
));

Enums use text labels. Without a labels map, the label is the constant's Dart name. When supplied, the map must cover every enum constant. Explicit labels allow a Dart rename without changing stored data. Ordinals are never stored. Duplicate labels fail generation; an unknown stored label fails decoding with CODEC.ENUM. The runtime equivalent is Codecs.enumeration with an explicit enum-to-label map.

This is portable text storage, with no native enum or automatically generated CHECK constraint. A database writer can therefore insert an unknown label; the decoder detects it when read. Adding or renaming stored labels requires a reviewed data migration and compatibility with any still-running application versions.

Structured JSON

Use a codec for a structured value, including a record:

import 'dart:convert';
import 'package:orm/schema.dart';

typedef Location = ({String city, int zone});
const locationCodec = Codec<Location>('json', decodeLocation, encodeLocation);

Location decodeLocation(Object? raw) {
  final json = Codecs.json.decode(raw) as Map<String, Object?>;
  return (city: json['city'] as String, zone: json['zone'] as int);
}
String encodeLocation(Location value) =>
    jsonEncode({'city': value.city, 'zone': value.zone});

final place = model('places', (
  id: identity(),
  location: custom(locationCodec).nullable(),
));

Use Codecs.json.decode(raw) inside a custom JSON decoder. SQLite supplies JSON text; PostgreSQL supplies parsed data wrapped in SqlJson. This bridge handles JSON strings without decoding them twice. A JSON document containing null is passed to the domain decoder; it does not bypass validation as SQL NULL does.

For an arbitrary document with explicit presence:

final event = model('events', (
  id: identity(),
  payload: json().nullable(),
));

// In generated create/patch arguments:
// null            -> SQL NULL
// SqlJson(null)   -> JSON null
// SqlJson('text') -> JSON string
// SqlJson({'a': 1}) -> JSON object

Read the document through .value. field.isNull() tests SQL nullness. For ordinary JSON values, Codecs.json returns the parsed Dart value, including null; that value alone cannot distinguish JSON null from SQL null. jsonEncode determines which payloads are serializable. Use a domain codec to validate their shape and reconstruct application types.

Raw PostgreSQL results also use SqlJson for non-SQL-null json/jsonb columns. Decode these with Codecs.json when consuming portable raw results. JSON values are not supported as relational keys; use scalar IDs for relationships.

Physical storage and migrations

Storage tag Built-in Dart value SQLite PostgreSQL
integer int INTEGER BIGINT
bigint BigInt TEXT NUMERIC
decimal Decimal collated TEXT NUMERIC
real double REAL DOUBLE PRECISION
text String, enums TEXT TEXT
boolean bool INTEGER BOOLEAN
instant UTC DateTime collated UTC text TIMESTAMPTZ
date LocalDate collated TEXT DATE
time LocalTime collated TEXT TIME WITHOUT TIME ZONE
local_datetime LocalDateTime collated TEXT TIMESTAMP WITHOUT TIME ZONE
blob Uint8List BLOB BYTEA
json JSON value or custom codec JSON text JSONB

Snapshots record physical storage, nullability, defaults and constraints. They do not embed application functions. Changing a codec's validation or enum labels without changing storage does not create a DDL diff; write an explicit data migration when existing stored values need conversion. Changing storage uses the normal reviewed migration diff/conversion workflow.

Storage semantics still belong to each database. In particular, SQLite bigint text retains exact digits but does not provide numeric text ordering/arithmetic. Native enum types remain a backend extension. Browser numeric limits and transport are described in SQLite web. Use Decimal for exact decimal data.

UTC instants

Declare an instant as DateTime. Generated writes accept UTC or local DateTime objects and preserve their actual instant; reads return UTC. Codecs.dateTime is a constant codec with the instant storage tag. Its text representation has six fractional digits, a +00 offset and an explicit BC suffix when applicable.

SQLite uses TEXT COLLATE orm_instant_v1; comparisons operate on instants rather than lexicographic text. This preserves microsecond order, equivalent offsets, BC and extended years, uniqueness, relationship keys, window aggregates and stable cursors. The same collation backs column indexes. PostgreSQL uses native TIMESTAMPTZ and binary decoding independent of session DateStyle and timezone.

Timestamp text may have a numeric UTC offset or Z. Zone-less database text is defined as UTC, including SQLite's CURRENT_TIMESTAMP default; it is never parsed using the process timezone. Invalid dates, leap seconds, excessive fractional precision and invalid offsets fail decoding. IANA timezone names are not parsed by this codec. Applications must explicitly resolve civil times and timezone rules before constructing the DateTime they store. SQLite's default itself has second resolution; precise application values retain their microseconds.

Supported instants run from 4714-11-24 00:00:00 BC through 275760-09-13 00:00:00 UTC. The upper bound follows Dart DateTime's range, and the lower bound follows PostgreSQL. The PostgreSQL decoder checks bounds before adding epoch offsets, so larger native values cannot wrap through int64. Raw finite values beyond DateTime's range and infinities remain text; typed instant decoding rejects them. For browser representation limits, see SQLite Web.

Native drivers advertise Capabilities.temporal, covering the local types below and UTC instants. Borrowed PostgreSQL pools must use postgresTypeRegistry() and PostgresDriver.borrow(pool, temporal: true). Without that capability, typed temporal queries fail before execution.

Upgrading historical timestamp storage

Previously generated timestamp snapshots retain their original storage meaning: SQLite TEXT with BINARY ordering, or PostgreSQL TIMESTAMPTZ. Their serialized snapshots, SQL and migration checksums remain unchanged. New generation emits instant, which produces a visible, reviewed type change. Do not recreate an applied migration from the current application schema.

final upgrade = Migration.diff(
  '0002_instants',
  dialect: .sqlite,
  from: previous.snapshot!,
  to: SchemaSnapshot(currentSchema),
  previous: previous.checksum,
  using: {
    'events': {'created_at': 'orm_instant_v1(created_at)'},
  },
);

For a PostgreSQL history use dialect: .postgres and the reviewed conversion using: {'events': {'created_at': 'created_at'}}. Only that history's expression is needed.

The SQLite converter parses and canonicalizes each value with UTC semantics; the rebuild installs the new collation. Invalid timestamps or newly equivalent unique keys abort the migration and roll back its changes. Audit zone-less legacy text before conversion: if an external writer stored local civil time there, provide a reviewed conversion using its actual timezone. PostgreSQL already stores instants, so the shown conversion preserves values; existing infinities or values beyond DateTime's range still require separate data review. Old cursor tokens carry the old storage tag and cannot be reused under the new ordering contract.

Unmanaged SQLite TEXT imports as String; the managed instant collation lets the importer infer DateTime without sampling data.

Local calendar values

final appointment = model('appointments', (
  id: identity(),
  day: date(),
  time: time(defaultSql: "'12:30'"),
  starts: localDateTime().nullable(),
));

// After generation:
await db.appointment.create(
  day: LocalDate(2024, 2, 29),
  time: Change.set(LocalTime(12, 30)),
  starts: LocalDateTime.parse('2024-02-29 12:30:00.000001'),
);

These values have no timezone or implied UTC instant. DateTime, strings and other local temporal types cannot be substituted in generated predicates or writes. LocalDateTime combines a LocalDate and LocalTime. Its add(Duration) uses calendar days of exactly 24 hours; it does not perform timezone or daylight saving conversion. LocalDate.addDays and daysUntil operate on calendar dates.

Value Supported finite range
LocalDate 4714-11-24 BC through 5874897-12-31
LocalTime 00:00:00 through 24:00:00, at microsecond resolution
LocalDateTime 4714-11-24 00:00:00 BC through 294276-12-31 23:59:59.999999

Constructors use astronomical year numbers: year 0 is 1 BC, and year -1 is 2 BC. Text parsing accepts those year numbers or PostgreSQL's trailing BC notation. Serialization uses era notation; times always include six fractional digits. Invalid calendar dates, offsets, leap seconds and excess fractional precision are rejected. 24:00 is a distinct time-of-day endpoint; combining it with a date normalizes to the next date at 00:00. Addition beyond the finite range fails. The exact endpoints come from PostgreSQL's timestamp constants. The Gregorian ordinal conversion uses March-based 400-year cycles, as described in civil date algorithms.

Codecs.date, Codecs.time and Codecs.localDateTime accept their corresponding value type or valid text, never a DateTime. An owned PostgreSQL pool uses native binary decoders, so it does not truncate these ranges to Dart's DateTime range. Binary reads remain independent of session DateStyle and timezone. Raw date and timestamp infinity values remain strings; typed calendar decoding rejects them. For a borrowed PostgreSQL pool, configure it before opening connections:

import 'package:orm/postgres.dart';
import 'package:postgres/postgres.dart' as pg;

final pool = pg.Pool<void>.withEndpoints(endpoints,
  settings: pg.PoolSettings(typeRegistry: postgresTypeRegistry()),
);
final db = Database(PostgresDriver.borrow(pool, temporal: true));

The default borrowed-pool capability is false; temporal queries fail before execution until explicitly enabled. The registry's text fallback accepts ISO DateStyle only. The capability covers these three scalar types and UTC instants; arrays, ranges, intervals, time with timezone and timezone conversions are still outside it.

SQLite stores text with orm_date_v1, orm_time_v1 or orm_local_datetime_v1 collations. They compare parsed calendar values, including equivalent text forms, BC dates and extended years. Predicates, min/max, grouping, unique keys, foreign keys, relations and keyset cursors preserve that ordering, and column indexes use the same collation. External SQLite connections need matching registrations to use those tables and indexes. No calendar-validation CHECK is emitted: invalid external text sorts after valid values and fails typed decoding.

Snapshots preserve distinct storage tags. Catalog import recognizes native PostgreSQL scalar types and the three managed SQLite collations. Plain SQLite TEXT does not imply a calendar type. Changing text to a calendar type requires a reviewed migration; newly equivalent unique keys can make conversion fail and roll back. Historical backfills persist canonical text keys for resumable paging.

SQL calendar arithmetic and timezone-rule conversion have no typed API in this version; they are separate extension boundaries. Use local types when the domain value actually has no timezone; use DateTime for resolved instants.

Temporal precision

final event = model('events', (
  id: identity(),
  clock: time(precision: 3),
  appointment: localDateTime(precision: 3),
  occurredAt: dateTime(precision: 0),
));

time, localDateTime and dateTime accept precision from 0 through 6, including on nullable columns. Manual Column declarations use temporalPrecision: 3, including for domain codecs with a temporal storage tag. Date-only and unrelated storage types do not accept temporal precision. Omitting precision retains microseconds. Explicit six and the default have the same canonical snapshot and DDL, preserving existing migration checksums.

Dart value PostgreSQL column SQLite column
LocalTime TIME(p) WITHOUT TIME ZONE collated TEXT with precision CHECK
LocalDateTime TIMESTAMP(p) WITHOUT TIME ZONE collated TEXT with precision CHECK
DateTime TIMESTAMPTZ(p) UTC text with precision CHECK

The precision range follows PostgreSQL's date/time type contract. ORM inserts, updates, batches and upserts coerce assigned expressions in SQL. SQLite defaults, computed expressions and migration copies use the same registered function. Raw PostgreSQL writes undergo native column coercion; raw SQLite writes with excess nonzero digits fail the CHECK instead of silently changing the input. External SQLite connections need these ORM functions and collations registered. Default/six-digit columns preserve microseconds without precision rounding.

Rounding matches PostgreSQL's timestamp implementation and time implementation: nearest representable value, with exact halves away from midnight for LocalTime and away from 2000-01-01 for timestamps. Instant rounding uses the UTC timeline. For example, at three digits, 2000-01-01 00:00:00.0005 becomes .001, while 1999-12-31 23:59:59.9995 becomes .999. A time can reach 24:00; a timestamp can move into the next day. Values outside the supported finite range are rejected.

Value methods and SQL expressions expose the same explicit operation:

final rounded = LocalTime.parse('12:00:00.1235').withPrecision(3);
final selected = await db.event
    .select((e) => e.appointment.withPrecision(0))
    .get();

withPrecision also applies to local timestamps and resolved DateTime instants; the DateTime operation returns UTC. SQL results preserve nullable types. The operation composes with projections, CTEs, grouping and streams. Column precision does not change the value codec or implicitly round predicate parameters. Use the returned stored key, or explicitly round a lookup value, when precision affects a primary/foreign key.

Catalog inspection, CLI inspection and import retain precision. Recognized SQLite precision checks and coercion wrappers are interpreted as column metadata; other checks remain visible. Import drafts defaults and computed SQL without adding a second SQLite wrapper. Changing precision is a type change and requires reviewed conversion expressions for the selected database. Rounding can merge unique keys; such a failure rolls back the data, schema and migration history together. Renames retain the precision metadata.

Signed integer column widths

final counter = model('counters', (
  id: integer(bits: 32).identity(),
  small: integer(bits: 16),
  optional: integer(bits: 32).nullable(),
  total: integer(),
));

integer(bits: ...) accepts 16, 32 or 64. Omitting it means 64. For manual tables, pass integerBits: 16 to Column, including columns with integer-backed domain codecs. Explicit 64 and the default have the same serialized schema and produce no migration difference.

Width belongs to the column metadata. It does not replace the int value codec or constrain an aggregate to its source column's range. PostgreSQL SUM over SMALLINT/INTEGER produces BIGINT; the generated API decodes wider results normally. Other arithmetic and overflow follow the database's native rules. See PostgreSQL's numeric types and aggregate return types.

Width PostgreSQL SQLite column constraint
16 SMALLINT integer storage value between -32768 and 32767
32 INTEGER integer storage value between -2147483648 and 2147483647
64/default BIGINT normal INTEGER storage

SQLite still uses its native variable-sized integer representation, not a forced two- or four-byte layout. For 16/32, DDL adds a CHECK which permits NULL when the column is nullable and otherwise requires an integer within the signed range. Default 64 retains ordinary SQLite INTEGER affinity. ORM writes use Dart int; SQLite's affinity and raw SQL rules are documented in its datatype reference.

Catalog inspection recognizes the emitted SQLite range checks while ignoring quoted defaults and comments. It does not claim to prove equivalence of arbitrary handwritten CHECK expressions; those remain unmanaged. Column checks and full schema verification both compare the inferred width. Catalog import emits integer(bits: ...) for PostgreSQL SMALLINT/INTEGER and recognized SQLite range checks.

Width changes are type changes in migration history. Supply reviewed conversion expressions for the selected database, even for widening. PostgreSQL alters the native type; SQLite rebuilds the table with the new constraint. Data outside a narrowed range fails and rolls back the migration/history together. Physical column renames retain the width. Changing an identity column's width preserves generation and, on PostgreSQL, the associated sequence type. Backfills use their saved width metadata when verifying the historical schema.

Native int supports signed 64-bit values. Browser int values use the safe-number range; use BigInt for wider exact transport. See SQLite Web for details.

Exact decimals

Use decimal() for finite base-ten values, including money. Generated fields use the Decimal Dart type and Codecs.decimal. See exact decimals for construction, arithmetic, numeric keys, SQLite storage requirements and current limits. BigInt storage does not provide these fractional or SQLite numeric ordering semantics.

Libraries

values Values and codecs
Exact numeric values, calendar values, and explicit storage codecs.