|
| 1 | +# Repository - persistence over any provider |
| 2 | + |
| 3 | +A repository is the persistence boundary for one entity type. `std/data` gives |
| 4 | +you two generic interfaces to implement on a small class: |
| 5 | +`Repository<TKey, TEntity, TModel>` for the read side, and |
| 6 | +`CrudRepository<TKey, TEntity, TModel>` which adds writes. The reads return a |
| 7 | +[`Query`](query.md) you compose with the full query API, and the writes go |
| 8 | +through the same [`QueryProvider`](query.md) contract, so one repository runs |
| 9 | +against an in-memory source in tests and a real database in production without |
| 10 | +changing a line. |
| 11 | + |
| 12 | +```x |
| 13 | +import "std/data.xi" |
| 14 | +
|
| 15 | +type User = { id: Integer, name: String, age: Integer, pw: String } |
| 16 | +type UserApi = { id: Integer, name: String, age: Integer } // no pw on the wire |
| 17 | +
|
| 18 | +class UserRepo implements CrudRepository<Integer, User, UserApi> { |
| 19 | + deps { db: QueryProvider } |
| 20 | + state { source: String = "users" } |
| 21 | +
|
| 22 | + producer findAll() -> Query<User> => query.from<User>(this.source) |
| 23 | + producer findById(id: Integer) -> User? { |
| 24 | + let rows = query.from<User>(this.source).filter { it.id == id }.take(1).toList() |
| 25 | + if rows.len() > 0 { return rows.get(0) } |
| 26 | + return none |
| 27 | + } |
| 28 | + consumer save(e: User) { db.remove(this.source, "id", json.int(e.id)) db.insert(this.source, e as Json) } |
| 29 | + consumer delete(e: User) { deleteById(e.id) } |
| 30 | + consumer deleteById(id: Integer) { db.remove(this.source, "id", json.int(id)) } |
| 31 | + // convertTo / convertFrom inherited as defaults (override to customize) |
| 32 | +} |
| 33 | +``` |
| 34 | + |
| 35 | +`findAll()` returns a query, not a list, so callers compose before running: |
| 36 | + |
| 37 | +```x |
| 38 | +let adults = repo.findAll() |
| 39 | + .filter { it.age >= 18 } |
| 40 | + .sortedBy { it.name } |
| 41 | + .toList() // runs through the bound provider |
| 42 | +``` |
| 43 | + |
| 44 | +## The interfaces |
| 45 | + |
| 46 | +```x |
| 47 | +interface Repository<TKey, TEntity, TModel> { |
| 48 | + producer findAll() -> Query<TEntity> |
| 49 | + producer findById(id: TKey) -> TEntity? |
| 50 | +
|
| 51 | + mapper convertTo(e: TEntity) -> TModel => (e as Json) as TModel |
| 52 | + mapper convertFrom(m: TModel) -> TEntity => (m as Json) as TEntity |
| 53 | +} |
| 54 | +
|
| 55 | +interface CrudRepository<TKey, TEntity, TModel> extends Repository<TKey, TEntity, TModel> { |
| 56 | + consumer save(e: TEntity) |
| 57 | + consumer delete(e: TEntity) |
| 58 | + consumer deleteById(id: TKey) |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +The three type parameters are the key type, the stored entity type, and an |
| 63 | +external **model** type (a DTO) used at the boundary. |
| 64 | + |
| 65 | +| Method | Kind | Purpose | |
| 66 | +|---|---|---| |
| 67 | +| `findAll()` | read | a `Query<TEntity>` over the whole source, ready to filter | |
| 68 | +| `findById(id)` | read | one entity or `none` | |
| 69 | +| `save(e)` | write | insert or replace by key | |
| 70 | +| `delete(e)` / `deleteById(id)` | write | remove by key | |
| 71 | +| `convertTo(e)` | map | entity to model | |
| 72 | +| `convertFrom(m)` | map | model to entity | |
| 73 | + |
| 74 | +## Entity and model conversion |
| 75 | + |
| 76 | +`convertTo` / `convertFrom` come with default bodies: a field-matched projection |
| 77 | +through the derived JSON codecs. Fields present in the target are copied by name, |
| 78 | +extras are dropped, and anything missing is zeroed. This is how a `User` with a |
| 79 | +`pw` field becomes a `UserApi` without it: |
| 80 | + |
| 81 | +```x |
| 82 | +let api = repo.convertTo(user) // pw dropped |
| 83 | +``` |
| 84 | + |
| 85 | +Override either method when the mapping is not a straight field match: |
| 86 | + |
| 87 | +```x |
| 88 | +mapper convertTo(e: User) -> UserApi => UserApi { id: e.id, name: e.name.toUpper(), age: e.age } |
| 89 | +``` |
| 90 | + |
| 91 | +## Binding a provider |
| 92 | + |
| 93 | +The repository holds a `QueryProvider` and calls `run` / `insert` / `remove` on |
| 94 | +it. Bind whichever provider you want; the repository does not change. |
| 95 | + |
| 96 | +```x |
| 97 | +module App { |
| 98 | + bind QueryProvider -> SqliteProvider as singleton // or MemorySource in tests |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +`MemorySource` from `std/query` implements the full read and write contract, so |
| 103 | +tests need no database: |
| 104 | + |
| 105 | +```x |
| 106 | +test "CRUD + query" (repo: CrudRepository<Integer, User, UserApi>) { |
| 107 | + repo.save(User { id: 1, name: "Cara", age: 44, pw: "s1" }) |
| 108 | + repo.save(User { id: 2, name: "Abe", age: 15, pw: "s2" }) |
| 109 | +
|
| 110 | + let adults = repo.findAll().filter { it.age >= 18 }.toList() |
| 111 | + assertEq(adults.len(), 1) |
| 112 | +
|
| 113 | + if let u = repo.findById(1) { assertEq(u.name, "Cara") } |
| 114 | +} |
| 115 | +``` |
| 116 | + |
| 117 | +The [`examples/data`](https://github.com/code-by-sia/xi/tree/main/examples/data) |
| 118 | +directory has the full in-memory test and a SQLite-backed demo that persists |
| 119 | +through a real `libsqlite3` connection. |
| 120 | + |
| 121 | +## Generics |
| 122 | + |
| 123 | +`Repository` and `CrudRepository` are **generic interfaces** - the first in the |
| 124 | +standard library. Xi resolves them by monomorphization: for each concrete |
| 125 | +`implements CrudRepository<Integer, User, UserApi>`, the compiler synthesizes a |
| 126 | +non-generic interface with the type parameters substituted, so vtables, casters, |
| 127 | +and dependency injection all work unchanged. See |
| 128 | +[Generics](language-guide.md#generics) in the language guide. |
0 commit comments