Skip to content

Commit 391bba0

Browse files
committed
feat(reposotory): add Reposirory and std/data to make implementing data
driven apps easier class UserRepo implements CrudRepository<Integer, User, UserApi> { deps { db: QueryProvider } state { source: String = "users" } producer findAll() -> Query<User> => query.from<User>(this.source) } - Repository helper clsses can be used alongside of a query provider to get Query results out of database that can be collected into lists.
1 parent 9743e8a commit 391bba0

15 files changed

Lines changed: 586 additions & 12 deletions

File tree

compiler/impl/codegen/postfix.xi

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,15 @@ mapper genPostfix(toks: Token[], pos: Integer, ctx: GCtx) -> ExprRes {
506506
code = code + ".data"
507507
typ = "ptr:" + string_slice(typ, 4, string_len(typ)).xnameFromArrSuffix()
508508
} else {
509+
if typ.startsWith2("opt_") and fld == "value" {
510+
// T?.value -> the wrapped value, typed as inner T.
511+
code = code + ".value"
512+
typ = string_slice(typ, 4, string_len(typ)).xnameFromArrSuffix()
513+
} else {
514+
if typ.startsWith2("opt_") and fld == "has_value" {
515+
code = code + ".has_value"
516+
typ = "Bool"
517+
} else {
509518
if typ.isPairXType() and (fld == "first" or fld == "second") {
510519
// Pair<A,B>.first / .second — cast the stored value back to A/B.
511520
let pex = typ.pairElem(0)
@@ -531,6 +540,8 @@ mapper genPostfix(toks: Token[], pos: Integer, ctx: GCtx) -> ExprRes {
531540
typ = ft
532541
}
533542
}
543+
}
544+
}
534545
}
535546
}
536547
}

compiler/impl/codegen/queryreify.xi

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -366,13 +366,22 @@ mapper genQueryFrom(toks: Token[], pos: Integer, ctx: GCtx) -> ExprRes {
366366
}
367367
p = p + 2
368368
if toks.kindAt(p) == 115 { p = p + 1 } // '>'
369-
let src = tname // default source: the type name
369+
// Source: a string literal, or any String expression (e.g. a repository's
370+
// `this.source` field). Default to the type name if `()` is empty.
371+
let srcC = "xc_string_from_cstr(\"" + tname + "\")"
370372
if toks.kindAt(p) == 100 {
371373
p = p + 1
372-
if toks.kindAt(p) == 4 { src = toks.textAt(p) p = p + 1 }
374+
if toks.kindAt(p) == 4 {
375+
srcC = "xc_string_from_cstr(\"" + toks.textAt(p) + "\")"
376+
p = p + 1
377+
} else { if toks.kindAt(p) != 101 {
378+
let se = genExpr(toks, p, ctx)
379+
srcC = se.code
380+
p = se.pos
381+
} }
373382
if toks.kindAt(p) == 101 { p = p + 1 }
374383
}
375-
let code = "(xc_QueryPlan_t){ .source = xc_string_from_cstr(\"" + src + "\"), .stages = xstd_list_new(sizeof(xc_QueryStage_t)), .rows = xstd_json_array() }"
384+
let code = "(xc_QueryPlan_t){ .source = " + srcC + ", .stages = xstd_list_new(sizeof(xc_QueryStage_t)), .rows = xstd_json_array() }"
376385
return ExprRes { code: code, pos: p, xtyp: "Query_" + tname, owned: false }
377386
}
378387

@@ -521,11 +530,10 @@ mapper genQueryStage(toks: Token[], p: Integer, recv: String, typ: String, ctx:
521530
}
522531

523532
if fld == "toList" {
524-
// run a list-rooted query locally: an ephemeral MemorySource interprets
525-
// the plan's inline rows — no provider argument needed.
526-
if not local {
527-
diag_error(line, "xi-query: .toList runs a list-rooted query (someList.asQuery()...) locally — a source-rooted query needs .collect(provider)")
528-
}
533+
// run the query with no explicit provider. A list-rooted query uses an
534+
// ephemeral MemorySource over its inline rows; a source-rooted query uses
535+
// the module's bound QueryProvider (DI) — this is what lets a repository's
536+
// findAll().filter{...}.toList() work.
529537
if elem.startsWith2("qpair:") or elem.startsWith2("qgroup:") {
530538
diag_error(line, "xi-query: project joined/grouped rows with .map { ... } before .toList")
531539
}
@@ -536,7 +544,9 @@ mapper genQueryStage(toks: Token[], p: Integer, recv: String, typ: String, ctx:
536544
if string_len(dec) == 0 {
537545
diag_error(line, "xi-query: can't decode rows into element type '" + elem + "'")
538546
}
539-
let code = "({ xc_QueryProvider_t __qv" + u + " = xc_MemorySource_as_QueryProvider(xc_new_MemorySource()); "
547+
let providerC = "xc_resolve_QueryProvider()"
548+
if local { providerC = "xc_MemorySource_as_QueryProvider(xc_new_MemorySource())" }
549+
let code = "({ xc_QueryProvider_t __qv" + u + " = " + providerC + "; "
540550
+ "xc_Json_t __qr" + u + " = __qv" + u + ".vtable->run(__qv" + u + ".self, " + recv + "); "
541551
+ "xc_List_t __ql" + u + " = xstd_list_new(sizeof(" + ct + ")); "
542552
+ "for (xc_integer_t __qi" + u + " = 0; __qi" + u + " < xstd_json_length(__qr" + u + "); __qi" + u + "++) { "

compiler/impl/codegen/stmt.xi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ mapper genStmt(toks: Token[], pos: Integer, ctx: GCtx) -> StmtRes {
158158
if rc == "{0}" {
159159
rc = "(" + ctx.retCtype + "){0}"
160160
}
161+
// Returning a present optional: a value of type X where the function
162+
// returns X? wraps into a `{ .has_value = 1, .value = <expr> }`.
163+
if ctx.retCtype.startsWith2("xc_opt_") and string_len(e.xtyp) > 0 and e.xtyp != "none"
164+
and ctx.retCtype == "xc_opt_" + e.xtyp.arrSuffixOf() + "_t" {
165+
rc = "(" + ctx.retCtype + "){ .has_value = 1, .value = " + rc + " }"
166+
}
161167
return StmtRes { code: " return " + rc + ";\n", ctx: ctx, pos: e.pos }
162168
}
163169
if k == 222 {

compiler/impl/codegen/top.xi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ mapper genArrTypedefs(prog: Program) -> String {
9898
if not isCompositeAlias(ts) {
9999
out = out + "typedef struct { xc_" + ts.name + "_t* data; xc_size_t len; xc_size_t cap; } xc_arr_" + ts.name + "_t;\n"
100100
out = out + "typedef xc_List_t xc_List_" + ts.name + "_t;\n" // List<ts> / Vec<ts>
101+
if prog.usesQuery() { out = out + "typedef xc_QueryPlan_t xc_Query_" + ts.name + "_t;\n" } // Query<ts>
101102
out = out + "typedef xc_Set_t xc_Set_" + ts.name + "_t;\n" // Set<ts>
102103
out = out + "typedef xc_Stack_t xc_Stack_" + ts.name + "_t;\n" // Stack<ts>
103104
out = out + "typedef xc_Queue_t xc_Queue_" + ts.name + "_t;\n" // Queue<ts>

compiler/impl/parser/type_parser.xi

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ mapper parseTypeExpr(ps: PState) -> TypeResult {
136136
if peek(ps2).kind == 115 { ps2 = advance(ps2) } // `>`
137137
base = "xc_Future_" + ctypeSuffix(elem.ctype) + "_t"
138138
} else {
139+
if t.text == "Query" and peekAt(ps, 1).kind == 114 { // Query<T> — a reified query plan
140+
let ep = advance(advance(ps))
141+
let elem = parseTypeExpr(ep)
142+
ps2 = elem.ps
143+
if peek(ps2).kind == 115 { ps2 = advance(ps2) } // `>`
144+
base = "xc_Query_" + ctypeSuffix(elem.ctype) + "_t"
145+
} else {
139146
if t.text == "Map" and peekAt(ps, 1).kind == 114 { // Map<K, V>
140147
let kp = advance(advance(ps)) // past `Map` and `<`
141148
let kt = parseTypeExpr(kp)
@@ -148,7 +155,7 @@ mapper parseTypeExpr(ps: PState) -> TypeResult {
148155
} else {
149156
base = identToCtype(t.text)
150157
ps2 = advance(ps)
151-
} } } } } } } }
158+
} } } } } } } } }
152159
} else {
153160
return TypeResult { ctype: "void", ps: ps }
154161
}

docs/data.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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.

docs/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ just need a C compiler (`cc`) on `PATH`. Full steps:
5858
- **Query** - [reified query chains](query.md): `query.from<User>("users")
5959
.filter { it.age >= 18 }.collect(db)` compiles to a typed plan a provider
6060
runs in memory or renders to SQL.
61+
- **Repository** - [`std/data`](data.md) generic `Repository` / `CrudRepository`
62+
interfaces bind to any provider: composable reads, provider-backed writes, and
63+
overridable entity ↔ model mapping.
6164
- **Native output** - compiles to a standalone binary via C; no VM, no GC.
6265

6366
## A taste

docs/language-guide.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,35 @@ interface WebRequestHandler {
474474
}
475475
```
476476

477+
### Generics
478+
479+
An interface may take **type parameters** in angle brackets, making it a
480+
template a class fills in when it implements a concrete instantiation:
481+
482+
```x
483+
interface Repository<TKey, TEntity> {
484+
producer findById(id: TKey) -> TEntity?
485+
consumer save(e: TEntity)
486+
}
487+
488+
class UserRepo implements Repository<Integer, User> {
489+
deps {}
490+
producer findById(id: Integer) -> User? { … }
491+
consumer save(e: User) { … }
492+
}
493+
```
494+
495+
Type parameters appear anywhere a type does - parameters, return types, and the
496+
arguments of an interface a template `extends`. A generic interface resolves by
497+
**monomorphization**: for each concrete `implements Repository<Integer, User>`,
498+
the compiler synthesizes a non-generic interface with the parameters
499+
substituted, so vtables, casters, and dependency injection (including resolving
500+
a generic interface from a class's `deps`) all work unchanged. Interfaces the
501+
template `extends` are flattened into the concrete interface as well.
502+
503+
The standard [`Repository` / `CrudRepository`](data.md) interfaces are built this
504+
way.
505+
477506
### Steering with `bind` (optional)
478507

479508
A `module` may `bind` an interface to a specific class, or mark a class as a

docs/stdlib.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ runs it. See [Query](query.md).
184184
|------|------------------|
185185
| `query.from<T>("src")` | root a query at a named source |
186186
| `list.asQuery()` / `arr.asQuery()` | root a query at an in-memory collection; run with `.toList()` |
187-
| `QueryProvider` | interface - `run(plan: QueryPlan) -> Json` |
187+
| `QueryProvider` | interface - `run(plan) -> Json` (read) + `insert(source, row)` / `remove(source, key, id)` (write) |
188188
| `RowStore` | interface - `load(name: String, rows: Json)` |
189189
| `MemorySource` | class - the in-memory reference provider (implements both) |
190190
| `QueryPlan` / `QueryStage` / `QueryExpr` | the plan's types - walk with `match`; serialize with `as Json` |
@@ -202,6 +202,17 @@ an interface - three are bundled; add your own without touching std.
202202
| `SqlDialect` | interface - name / placeholder / quoteIdent / callSql / regexpExpr / limitSql |
203203
| `SqliteDialect` / `PostgresDialect` / `MysqlDialect` | bundled dialects (`.name()` -> `"sqlite"` / `"postgres"` / `"mysql"`) |
204204

205+
### `data` - `std/data.xi`
206+
207+
Standard **repository** interfaces over any `QueryProvider`: reads return a
208+
composable [`Query`](query.md), writes go through the provider's write contract,
209+
and entity ↔ model conversion has overridable defaults. See [Repository](data.md).
210+
211+
| Name | Kind / Signature |
212+
|------|------------------|
213+
| `Repository<TKey, TEntity, TModel>` | interface - `findAll() -> Query<TEntity>` / `findById(id) -> TEntity?` + `convertTo` / `convertFrom` defaults |
214+
| `CrudRepository<TKey, TEntity, TModel>` | interface - extends `Repository` with `save` / `delete` / `deleteById` |
215+
205216
### `thread` - `std/thread.xi`
206217

207218
**Share-nothing threads + channels.** A `parallel` block runs on a new OS thread

examples/data/repository_test.xi

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import "std/data.xi"
2+
import "std/query.xi"
3+
import "std/json.xi"
4+
5+
type User = { id: Integer, name: String, age: Integer, pw: String }
6+
type UserApi = { id: Integer, name: String, age: Integer } // no pw on the wire
7+
8+
class UserRepo implements CrudRepository<Integer, User, UserApi> {
9+
deps { db: QueryProvider }
10+
state { source: String = "users" }
11+
12+
producer findAll() -> Query<User> => query.from<User>(this.source)
13+
producer findById(id: Integer) -> User? {
14+
let rows = query.from<User>(this.source).filter { it.id == id }.take(1).toList()
15+
if rows.len() > 0 { return rows.get(0) }
16+
return none
17+
}
18+
consumer save(e: User) { db.remove(this.source, "id", json.int(e.id)) db.insert(this.source, e as Json) }
19+
consumer delete(e: User) { deleteById(e.id) }
20+
consumer deleteById(id: Integer) { db.remove(this.source, "id", json.int(id)) }
21+
// convertTo / convertFrom inherited as defaults
22+
}
23+
24+
module App { bind QueryProvider -> MemorySource as singleton }
25+
26+
// The repo is auto-resolved (sole implementor); injected by its generic interface.
27+
test "CRUD + query + convert" (repo: CrudRepository<Integer, User, UserApi>) {
28+
repo.save(User { id: 1, name: "Cara", age: 44, pw: "s1" })
29+
repo.save(User { id: 2, name: "Abe", age: 15, pw: "s2" })
30+
repo.save(User { id: 3, name: "Bea", age: 30, pw: "s3" })
31+
32+
let adults = repo.findAll().filter { it.age >= 18 }.sortedBy { it.name }.toList()
33+
assertEq(adults.len(), 2)
34+
assertEq(adults.get(0).name, "Bea")
35+
36+
let u = repo.findById(1)
37+
assert u.has_value
38+
assertEq(u.value.name, "Cara")
39+
40+
let api = repo.convertTo(u.value) // default: drops pw
41+
assertEq(json.stringify(api as Json), "{\"id\":1,\"name\":\"Cara\",\"age\":44}")
42+
43+
repo.save(User { id: 1, name: "Cara2", age: 45, pw: "x" }) // upsert
44+
assertEq((repo.findById(1)).value.name, "Cara2")
45+
46+
repo.delete(User { id: 1, name: "", age: 0, pw: "" })
47+
assert not (repo.findById(1)).has_value
48+
}

0 commit comments

Comments
 (0)