diff --git a/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md b/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md index 93392bb7..b45c0cdd 100644 --- a/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md +++ b/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md @@ -81,3 +81,5 @@ var result = await db.unsafeQuery( :::danger Always sanitize your input when using raw query methods. For the `unsafeQuery` and `unsafeExecute` methods, use query parameters to prevent SQL injection. Avoid using `unsafeSimpleQuery` and `unsafeSimpleExecute` unless the simple query protocol is strictly required. ::: + +To apply tracing, metrics, tenant scoping, or policy enforcement across every database operation of a session, including the raw methods above, see [Database interceptors](./database-interceptors). diff --git a/docs/06-concepts/03-data-and-the-database/02-database/17-database-interceptors.md b/docs/06-concepts/03-data-and-the-database/02-database/17-database-interceptors.md new file mode 100644 index 00000000..ca8d2473 --- /dev/null +++ b/docs/06-concepts/03-data-and-the-database/02-database/17-database-interceptors.md @@ -0,0 +1,77 @@ +--- +description: A database interceptor replaces Session.db once per session with a custom database layer for tracing, policy enforcement, tenant scoping, and tests. +--- + +# Database interceptors + +A database interceptor lets you wrap every database operation of a session in the same behavior, such as query tracing, tenant scoping, or a guard that rejects writes in a protected environment. Serverpod calls the interceptor once when it creates a session, passing the session and the framework database, and whatever the interceptor returns becomes `session.db` for the rest of that session. + +Register the interceptor when you construct `Serverpod`: + +```dart +var pod = Serverpod( + args, + Protocol(), + Endpoints(), + databaseInterceptor: (session, inner) { + session.log('Created a database layer for this session.'); + return inner; + }, +); +``` + +Returning `inner` leaves the framework database in place, so this example only observes session creation. Repeated reads of `session.db` return the same instance for the lifetime of the session. + +## Return a custom database layer + +To intercept the operations themselves, return your own implementation of the `Database` interface exported by `package:serverpod/serverpod.dart`: + +```dart +var pod = Serverpod( + args, + Protocol(), + Endpoints(), + databaseInterceptor: (session, inner) { + return PolicyDatabase( + session: session, + inner: inner, + ); + }, +); +``` + +The `Database` class has no public constructor to subclass, so implement the interface and let your IDE generate the overrides. Hold on to the supplied `inner` database and delegate every operation the layer does not change. The object you return becomes `Session.db`, so generated calls such as `User.db.find(session)` pass through it as well. + +Typical uses are: + +- Recording query traces, timings, and metrics. +- Applying a tenant scope to every supported query. +- Enforcing read and write policies. +- Blocking unsafe operations or writes in protected environments. +- Recording or replacing database behavior in tests. + +Pass transaction objects, [runtime parameters](./runtime-parameters), ordering, pagination, [row locks](./row-locking), and return options through without changing their meaning. A layer that enforces a policy also needs to cover [raw access](./raw-access) and transactions, otherwise a caller can reach the database around it. Do not hold on to the session or its inner database after the session closes. + +:::warning +Custom database layers build on the `Database` API from `serverpod_database`, which can change in a breaking way in a minor Serverpod release. Review and test your implementation whenever you upgrade Serverpod. +::: + +## Use an interceptor in tests + +The generated [`withServerpod`](../../testing/the-basics) test helper takes the same `databaseInterceptor` callback, so a test can install a recording or restricting database layer without touching the server startup code. The callback runs once for every session the helper creates. + +```dart +var recorder = QueryRecorder(); + +withServerpod( + 'Given Companies endpoint', + (sessionBuilder, endpoints) { + test('then calling `all` records the query', () async { + await endpoints.companies.all(sessionBuilder); + expect(recorder.queries, isNotEmpty); + }); + }, + databaseInterceptor: (session, inner) => + RecordingDatabase(inner: inner, recorder: recorder), +); +``` diff --git a/docs/06-concepts/08-testing/02-the-basics.md b/docs/06-concepts/08-testing/02-the-basics.md index cef7a338..295045df 100644 --- a/docs/06-concepts/08-testing/02-the-basics.md +++ b/docs/06-concepts/08-testing/02-the-basics.md @@ -135,6 +135,7 @@ The following optional configuration options are available to pass as named argu |:-----|:-----|:---:| |`applyMigrations`|Whether pending migrations should be applied when starting Serverpod.|`true`| |`configOverride`|A function that overrides values in the loaded Serverpod config before the test server starts.|`null`| +|`databaseInterceptor`|A callback that returns the database used as `Session.db` for each test session. See [Database interceptors](../data-and-the-database/database/database-interceptors).|`null`| |`enableSessionLogging`|Whether session logging should be enabled.|`false`| |`experimentalFeatures`|Optionally specify experimental features used by Serverpod.|`null`| |`rollbackDatabase`|Options for when to rollback the database during the test lifecycle (or disable it). See detailed description [here](#rollback-database-configuration).|`RollbackDatabase.afterEach`|