DART · STEP 4 · MODELS
Dart · shelf: models and migrations
Connect the shelf app to Postgres with the postgres 3.x driver (a pooled connection read from environment variables), create a table with a SQL migration file, write a model class that runs parameterized queries, and expose it through a controller.
What you will learn#
- How to set the database settings
- How to create models and migrations
- How to use models to add, update, and delete records
Setup#
Use the same
firstprojectfrom this morning's lessonAdd the Postgres driver:
dart pub add postgres(this writespostgres: ^3.5.20intopubspec.yamland fetches it, likenpm install)Make sure Postgres is running locally:
psql --versionandpsql postgres -c "select 1"should both work
Setting Up the Database Settings#
The driver needs five things to reach a database: host, port, database name, user and password. We read them from environment variables and fall back to local defaults, so the same file works on your laptop and on the server.
Create lib/db/connection.dart:
import 'dart:io' show Platform; import 'package:postgres/postgres.dart'; /// One connection pool for the whole app, created the first time it's used./// Opening a fresh connection on every request leaks sockets; a pool keeps a/// few open and hands them out as needed.final Pool db = Pool.withEndpoints( [ Endpoint( host: _env('PGHOST', 'localhost'), port: int.parse(_env('PGPORT', '5432')), database: _env('PGDATABASE', 'dartpets'), username: _env('PGUSER', 'postgres'), password: _env('PGPASSWORD', 'postgres'), ), ], settings: PoolSettings( maxConnectionCount: 5, // postgres 3.x REQUIRES SSL by default. A local Postgres has no TLS, so // turn it off unless PGSSLMODE asks for it (hosted databases will). sslMode: _env('PGSSLMODE', 'disable') == 'disable' ? SslMode.disable : SslMode.require, ),); /// Read an environment variable, or use the local-development default.String _env(String key, String fallback) => Platform.environment[key] ?? fallback;Change the two
'postgres'defaults to your local Postgres user and password if they differ.Create the database:
createdb dartpets
WHY these names: PGHOST, PGUSER and friends are the variable names psql itself reads (libpq's environment variables), so PGDATABASE=dartpets psql opens the same database your app uses. Platform.environment[key] returns String? — the ?? fallback is what makes _env return a plain String.
If you ever need a single connection instead of a pool — a one-off script, say — it's final conn = await Connection.open(Endpoint(…), settings: ConnectionSettings(sslMode: SslMode.disable)); and await conn.close() when done. Pool and Connection both implement Session, which is the type our model will accept.
Models and Migrations#
shelf has no ORM, so we do the two jobs an ORM would do ourselves:
- define the tables with SQL (the migration)
- write a model class whose methods run the right queries
Making the turtles table#
Keep table definitions in version control as numbered SQL files. Create db/migrations/001_create_turtles.sql in the project root:
CREATE TABLE IF NOT EXISTS turtles ( id SERIAL PRIMARY KEY, name VARCHAR(30) NOT NULL, age INTEGER NOT NULL);Run it:
psql dartpets -f db/migrations/001_create_turtles.sqlSeed a test record:
psql dartpets -c "INSERT INTO turtles (name, age) VALUES ('Filbert', 5);"Check:
psql dartpets -c "SELECT * FROM turtles;"
Every later change to the schema is a new file (002_add_shell_color.sql, …) run the same way, in order.
Making the turtles model#
Create lib/models/turtle.dart. The model holds a Session (the pool) and one method per database operation:
import 'package:postgres/postgres.dart'; class Turtle { // Session is implemented by both Pool and Connection, so either works here. final Session db; Turtle(this.db); // Each row comes back as a List of values; toColumnMap() turns it into // {'id': 1, 'name': 'Filbert', 'age': 5}, which jsonEncode understands. List<Map<String, dynamic>> _rows(Result result) => result.map((row) => row.toColumnMap()).toList(); // all turtles Future<List<Map<String, dynamic>>> getAll() async { final result = await db.execute('SELECT * FROM turtles ORDER BY id'); return _rows(result); } // one turtle, or null if the id doesn't exist Future<Map<String, dynamic>?> getOne(int id) async { // Sql.named lets us write @id; `parameters` fills it in safely (no SQL injection) final result = await db.execute( Sql.named('SELECT * FROM turtles WHERE id = @id'), parameters: {'id': id}, ); return _rows(result).firstOrNull; } // insert one turtle and return the new row (RETURNING * gives us the id) Future<Map<String, dynamic>> insert({required String name, required int age}) async { final result = await db.execute( Sql.named('INSERT INTO turtles (name, age) VALUES (@name, @age) RETURNING *'), parameters: {'name': name, 'age': age}, ); return _rows(result).first; } // update one turtle and return the updated row, or null if the id doesn't exist Future<Map<String, dynamic>?> update(int id, {required String name, required int age}) async { final result = await db.execute( Sql.named('UPDATE turtles SET name = @name, age = @age WHERE id = @id RETURNING *'), parameters: {'id': id, 'name': name, 'age': age}, ); return _rows(result).firstOrNull; } // delete one turtle and return what was deleted, or null if the id doesn't exist Future<Map<String, dynamic>?> destroy(int id) async { final result = await db.execute( Sql.named('DELETE FROM turtles WHERE id = @id RETURNING *'), parameters: {'id': id}, ); return _rows(result).firstOrNull; }}Things to notice:
executereturns aResult, which is aList<ResultRow>plusaffectedRows.firstOrNull(Dart 3) givesnullfor an empty result instead of throwing like[0]would.- Named parameters in the Dart method (
{required String name, required int age}) are unrelated to the@nameparameters in the SQL — one is Dart syntax, the other is the driver's. Both exist to stop you from mixing up the order. - Never build SQL with string interpolation from request data.
Sql.named+parameterssends values separately from the query text, so'Filbert; DROP TABLE turtles'is just a weird name.
Using the Model#
Initiating the Model#
The model is a plain class that takes the pool: Turtle(db). Because db is a top-level final, Dart creates the pool lazily the first time any code touches it, and every model shares it.
Trying it from a script#
Before wiring it to HTTP, drive the model from a script — Dart's version of a framework shell. Create bin/try_turtles.dart:
import 'package:firstproject/db/connection.dart';import 'package:firstproject/models/turtle.dart'; void main() async { final turtles = Turtle(db); final wilbert = await turtles.insert(name: 'Wilbert', age: 5); print(wilbert); // {id: 2, name: Wilbert, age: 5} final id = wilbert['id'] as int; print(await turtles.getOne(id)); print(await turtles.update(id, name: 'Dilbert', age: 6)); print(await turtles.destroy(id)); print(await turtles.getAll()); // back to just Filbert await db.close(); // close the pool so the program can exit}- Run it with
dart run bin/try_turtles.dart. If it can't connect, the error names the problem: wrong password, database doesn't exist, or "Server does not support SSL" (setPGSSLMODE=disableor fix the default inconnection.dart).
Creating the TurtleController#
Now a controller that calls the model. Create lib/controllers/turtle_controller.dart, reusing jsonResponse from this morning:
import 'dart:convert'; import 'package:shelf/shelf.dart';import 'package:shelf_router/shelf_router.dart'; import '../db/connection.dart';import '../json.dart';import '../models/turtle.dart'; class TurtleController { // one model, sharing the app-wide pool final Turtle turtles = Turtle(db); Router get router { final router = Router(); // GET /turtles router.get('/', (Request request) async { return jsonResponse(await turtles.getAll()); }); // GET /turtles/<id> — the regex only matches digits, so int.parse can't throw router.get('/<id|[0-9]+>', (Request request, String id) async { final turtle = await turtles.getOne(int.parse(id)); if (turtle == null) return jsonResponse({'error': 'turtle not found'}, status: 404); return jsonResponse(turtle); }); // POST /turtles with a JSON body {"name": "...", "age": 5} router.post('/', (Request request) async { final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>; final created = await turtles.insert(name: body['name'] as String, age: body['age'] as int); return jsonResponse(created, status: 201); }); // PUT /turtles/<id> router.put('/<id|[0-9]+>', (Request request, String id) async { final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>; final updated = await turtles.update(int.parse(id), name: body['name'] as String, age: body['age'] as int); if (updated == null) return jsonResponse({'error': 'turtle not found'}, status: 404); return jsonResponse(updated); }); // DELETE /turtles/<id> router.delete('/<id|[0-9]+>', (Request request, String id) async { final deleted = await turtles.destroy(int.parse(id)); if (deleted == null) return jsonResponse({'error': 'turtle not found'}, status: 404); return jsonResponse(deleted); }); return router; }}- Mount it in
bin/server.dartunder the other controllers:
import 'package:firstproject/controllers/turtle_controller.dart'; // Configure routes.final _router = Router() ..get('/', _rootHandler) ..get('/echo/<message>', _echoHandler) ..mount('/first', FirstController().router.call) ..mount('/turtles', TurtleController().router.call);- Run
dart run bin/server.dartand test every route as you go:
curl http://localhost:8080/turtlescurl -X POST http://localhost:8080/turtles -H "Content-Type: application/json" -d '{"name": "Wilbert", "age": 5}'curl http://localhost:8080/turtles/2curl -X PUT http://localhost:8080/turtles/2 -H "Content-Type: application/json" -d '{"name": "Dilbert", "age": 6}'curl -X DELETE http://localhost:8080/turtles/2curl http://localhost:8080/turtles/999The as String / as int casts turn a wrong-shaped body into a TypeError, which shelf reports as a 500. That's acceptable today; tomorrow's API lesson adds a 400 for bad input. All your routes should be working now — off to lab/homework!
Resources to Learn More#
- postgres package — the README covers
Connection.open,execute, named parameters, transactions and pooling - postgres API reference —
Pool,Endpoint,ConnectionSettings,Sql,Result - libpq environment variables —
PGHOST,PGUSER,PGPASSWORD,PGDATABASE,PGSSLMODE - shelf_router
Router— regex-constrained params like<id|[0-9]+>
See also