1/**
2 * Builds SQL repositories and request resolvers from Effect schema models.
3 *
4 * Use this module when a schema `Model` represents rows in a SQL table and the
5 * usual insert, update, find-by-id, delete, and batching behavior should be
6 * derived from that model. The helpers encode insert and update input with the
7 * model's input schemas and decode returned rows with the full model schema.
8 * Soft deletes are optional, and SQL dialect differences such as `returning`
9 * support are handled by the repository implementation.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14import type * as Cause from "../Cause.ts"
15import * as Effect from "../Effect.ts"
16import * as RequestResolver from "../RequestResolver.ts"
17import type * as Schema from "../Schema.ts"
18import type * as Model from "../schema/Model.ts"
19import type { Scope } from "../Scope.ts"
20import { SqlClient } from "./SqlClient.ts"
21import type { ResultLengthMismatch, SqlError } from "./SqlError.ts"
22import * as SqlResolver from "./SqlResolver.ts"
23import * as SqlSchema from "./SqlSchema.ts"
24
25/**
26 * Creates a CRUD repository for a schema model backed by a SQL table, with
27 * insert, update, find-by-id, and delete operations. When `softDeleteColumn` is
28 * supplied, reads ignore soft-deleted rows and delete updates that column
29 * instead of removing the row.
30 *
31 * @stability unstable
32 * @category constructors
33 * @since 4.0.0
34 */
35export const makeRepository = <
36 S extends Model.Any,
37 Id extends (keyof S["Type"]) & (keyof S["update"]["Type"]) & (keyof S["fields"]),
38 SoftDelete extends keyof S["fields"] = never
39>(Model: S, options: {
40 readonly tableName: string
41 readonly spanPrefix: string
42 readonly idColumn: Id
43 readonly softDeleteColumn?: SoftDelete | undefined
44}): Effect.Effect<
45 {
46 readonly insert: (
47 insert: S["insert"]["Type"]
48 ) => Effect.Effect<
49 S["Type"],
50 Schema.SchemaError | SqlError,
51 S["DecodingServices"] | S["insert"]["EncodingServices"]
52 >
53 readonly insertVoid: (
54 insert: S["insert"]["Type"]
55 ) => Effect.Effect<void, Schema.SchemaError | SqlError, S["insert"]["EncodingServices"]>
56 readonly update: (
57 update: S["update"]["Type"]
58 ) => Effect.Effect<
59 S["Type"],
60 Schema.SchemaError | SqlError,
61 S["DecodingServices"] | S["update"]["EncodingServices"]
62 >
63 readonly updateVoid: (
64 update: S["update"]["Type"]
65 ) => Effect.Effect<void, Schema.SchemaError | SqlError, S["update"]["EncodingServices"]>
66 readonly findById: (
67 id: S["fields"][Id]["Type"]
68 ) => Effect.Effect<
69 S["Type"],
70 Cause.NoSuchElementError | Schema.SchemaError | SqlError,
71 S["DecodingServices"] | S["fields"][Id]["EncodingServices"]
72 >
73 readonly delete: (
74 id: S["fields"][Id]["Type"]
75 ) => Effect.Effect<void, Schema.SchemaError | SqlError, S["fields"][Id]["EncodingServices"]>
76 },
77 never,
78 SqlClient
79> =>
80 Effect.gen(function*() {
81 const sql = yield* SqlClient
82 const idSchema = Model.fields[options.idColumn]
83 const idColumn = options.idColumn as string
84 const softDeleteColumn = options.softDeleteColumn as string | undefined
85 const withSoftDeleteFilter = (where: any) =>
86 softDeleteColumn === undefined ? where : sql.and([where, sql`${sql(softDeleteColumn)} is null`])
87 const setSoftDeleted = softDeleteColumn === undefined
88 ? undefined
89 : sql`${sql(softDeleteColumn)} = CURRENT_TIMESTAMP`
90
91 const insertSchema = SqlSchema.findOne({
92 Request: Model.insert,
93 Result: Model,
94 execute: (request) =>
95 sql.onDialectOrElse({
96 mysql: () =>
97 sql`insert into ${sql(options.tableName)} ${sql.insert(request as any)};
98select * from ${sql(options.tableName)} where ${withSoftDeleteFilter(sql`${sql(idColumn)} = LAST_INSERT_ID()`)};`
99 .unprepared.pipe(
100 Effect.map(([, results]) => results as any)
101 ),
102 orElse: () => sql`insert into ${sql(options.tableName)} ${sql.insert(request as any).returning("*")}`
103 })
104 })
105 const insert = (
106 insert: S["insert"]["Type"]
107 ): Effect.Effect<
108 S["Type"],
109 Schema.SchemaError | SqlError,
110 S["DecodingServices"] | S["insert"]["EncodingServices"]
111 > =>
112 insertSchema(insert).pipe(
113 Effect.catchTag("NoSuchElementError", Effect.die),
114 Effect.withSpan(`${options.spanPrefix}.insert`, {}, { captureStackTrace: false })
115 ) as any
116
117 const insertVoidSchema = SqlSchema.void({
118 Request: Model.insert,
119 execute: (request) => sql`insert into ${sql(options.tableName)} ${sql.insert(request as any)}`
120 })
121 const insertVoid = (
122 insert: S["insert"]["Type"]
123 ): Effect.Effect<void, Schema.SchemaError | SqlError, S["insert"]["EncodingServices"]> =>
124 insertVoidSchema(insert).pipe(
125 Effect.withSpan(`${options.spanPrefix}.insertVoid`, {}, {
126 captureStackTrace: false
127 })
128 ) as any
129
130 const updateSchema = SqlSchema.findOne({
131 Request: Model.update,
132 Result: Model,
133 execute: (request: any) =>
134 sql.onDialectOrElse({
135 mysql: () =>
136 sql`update ${sql(options.tableName)} set ${sql.update(request, [idColumn])} where ${
137 withSoftDeleteFilter(sql`${sql(idColumn)} = ${request[idColumn]}`)
138 };
139select * from ${sql(options.tableName)} where ${withSoftDeleteFilter(sql`${sql(idColumn)} = ${request[idColumn]}`)};`
140 .unprepared.pipe(
141 Effect.map(([, results]) => results as any)
142 ),
143 orElse: () =>
144 sql`update ${sql(options.tableName)} set ${sql.update(request, [idColumn])} where ${
145 withSoftDeleteFilter(sql`${sql(idColumn)} = ${request[idColumn]}`)
146 } returning *`
147 })
148 })
149 const update = (
150 update: S["update"]["Type"]
151 ): Effect.Effect<
152 S["Type"],
153 Schema.SchemaError | SqlError,
154 S["DecodingServices"] | S["update"]["EncodingServices"]
155 > =>
156 updateSchema(update).pipe(
157 Effect.catchTag("NoSuchElementError", Effect.die),
158 Effect.withSpan(`${options.spanPrefix}.update`, {
159 attributes: { id: (update as any)[idColumn] }
160 }, {
161 captureStackTrace: false
162 })
163 ) as any
164
165 const updateVoidSchema = SqlSchema.void({
166 Request: Model.update,
167 execute: (request: any) =>
168 sql`update ${sql(options.tableName)} set ${sql.update(request, [idColumn])} where ${
169 withSoftDeleteFilter(sql`${sql(idColumn)} = ${request[idColumn]}`)
170 }`
171 })
172 const updateVoid = (
173 update: S["update"]["Type"]
174 ): Effect.Effect<void, Schema.SchemaError | SqlError, S["update"]["EncodingServices"]> =>
175 updateVoidSchema(update).pipe(
176 Effect.withSpan(`${options.spanPrefix}.updateVoid`, {
177 attributes: { id: (update as any)[idColumn] }
178 }, {
179 captureStackTrace: false
180 })
181 ) as any
182
183 const findByIdSchema = SqlSchema.findOne({
184 Request: idSchema,
185 Result: Model,
186 execute: (id: any) =>
187 sql`select * from ${sql(options.tableName)} where ${withSoftDeleteFilter(sql`${sql(idColumn)} = ${id}`)}`
188 })
189 const findById = (
190 id: S["fields"][Id]["Type"]
191 ): Effect.Effect<
192 S["Type"],
193 Cause.NoSuchElementError | Schema.SchemaError | SqlError,
194 S["DecodingServices"] | S["fields"][Id]["EncodingServices"]
195 > =>
196 findByIdSchema(id).pipe(
197 Effect.withSpan(`${options.spanPrefix}.findById`, { attributes: { id } }, {
198 captureStackTrace: false
199 })
200 ) as any
201
202 const deleteSchema = SqlSchema.void({
203 Request: idSchema,
204 execute: (id: any) =>
205 softDeleteColumn === undefined
206 ? sql`delete from ${sql(options.tableName)} where ${sql(idColumn)} = ${id}`
207 : sql`update ${sql(options.tableName)} set ${setSoftDeleted} where ${
208 withSoftDeleteFilter(sql`${sql(idColumn)} = ${id}`)
209 }`
210 })
211 const delete_ = (
212 id: S["fields"][Id]["Type"]
213 ): Effect.Effect<void, Schema.SchemaError | SqlError, S["fields"][Id]["EncodingServices"]> =>
214 deleteSchema(id).pipe(
215 Effect.withSpan(`${options.spanPrefix}.delete`, {
216 attributes: { id }
217 }, {
218 captureStackTrace: false
219 })
220 ) as any
221
222 return { insert, insertVoid, update, updateVoid, findById, delete: delete_ } as const
223 })
224
225/**
226 * Creates batched request resolvers for a schema model's insert, insert-void,
227 * find-by-id, and delete operations, honoring the optional soft-delete column.
228 *
229 * @stability unstable
230 * @category constructors
231 * @since 4.0.0
232 */
233export const makeResolvers = <
234 S extends Model.Any,
235 Id extends (keyof S["Type"]) & (keyof S["update"]["Type"]) & (keyof S["fields"]),
236 SoftDelete extends keyof S["fields"] = never
237>(
238 Model: S,
239 options: {
240 readonly tableName: string
241 readonly spanPrefix: string
242 readonly idColumn: Id
243 readonly softDeleteColumn?: SoftDelete | undefined
244 }
245): Effect.Effect<
246 {
247 readonly insert: RequestResolver.RequestResolver<
248 SqlResolver.SqlRequest<
249 S["insert"]["Type"],
250 S["Type"],
251 ResultLengthMismatch | SqlError,
252 S["DecodingServices"] | S["insert"]["EncodingServices"]
253 >
254 >
255 readonly insertVoid: RequestResolver.RequestResolver<
256 SqlResolver.SqlRequest<S["insert"]["Type"], void, SqlError, S["insert"]["EncodingServices"]>
257 >
258 readonly findById: RequestResolver.RequestResolver<
259 SqlResolver.SqlRequest<
260 S["fields"][Id]["Type"],
261 S["Type"],
262 Cause.NoSuchElementError | SqlError,
263 S["DecodingServices"] | S["fields"][Id]["EncodingServices"]
264 >
265 >
266 readonly delete: RequestResolver.RequestResolver<
267 SqlResolver.SqlRequest<
268 S["fields"][Id]["Type"],
269 void,
270 SqlError,
271 S["fields"][Id]["EncodingServices"]
272 >
273 >
274 },
275 never,
276 SqlClient | Scope
277> =>
278 Effect.gen(function*() {
279 const sql = yield* SqlClient
280 const idSchema = Model.fields[options.idColumn]
281 const idColumn = options.idColumn as string
282 const softDeleteColumn = options.softDeleteColumn as string | undefined
283 const withSoftDeleteFilter = (where: any) =>
284 softDeleteColumn === undefined ? where : sql.and([where, sql`${sql(softDeleteColumn)} is null`])
285 const setSoftDeleted = softDeleteColumn === undefined
286 ? undefined
287 : sql`${sql(softDeleteColumn)} = CURRENT_TIMESTAMP`
288
289 const insert: RequestResolver.RequestResolver<
290 SqlResolver.SqlRequest<
291 S["insert"]["Type"],
292 S["Type"],
293 ResultLengthMismatch | SqlError,
294 S["DecodingServices"] | S["insert"]["EncodingServices"]
295 >
296 > = SqlResolver.ordered({
297 Request: Model.insert,
298 Result: Model,
299 execute: (request: any) =>
300 sql.onDialectOrElse({
301 mysql: () =>
302 Effect.forEach(request, (request: any) =>
303 sql`insert into ${sql(options.tableName)} ${sql.insert(request)};
304select * from ${sql(options.tableName)} where ${withSoftDeleteFilter(sql`${sql(idColumn)} = LAST_INSERT_ID()`)};`
305 .unprepared.pipe(
306 Effect.map(([, results]) => results[0] as any)
307 ), { concurrency: 10 }),
308 orElse: () => sql`insert into ${sql(options.tableName)} ${sql.insert(request).returning("*")}`
309 })
310 }).pipe(
311 RequestResolver.withSpan(`${options.spanPrefix}.insertResolver`)
312 )
313
314 const insertVoid: RequestResolver.RequestResolver<
315 SqlResolver.SqlRequest<S["insert"]["Type"], void, Schema.SchemaError | SqlError, S["insert"]["EncodingServices"]>
316 > = SqlResolver.void({
317 Request: Model.insert,
318 execute: (request: any) => sql`insert into ${sql(options.tableName)} ${sql.insert(request)}`
319 }).pipe(
320 RequestResolver.withSpan(`${options.spanPrefix}.insertVoidResolver`)
321 )
322
323 const findById: RequestResolver.RequestResolver<
324 SqlResolver.SqlRequest<
325 S["fields"][Id]["Type"],
326 S["Type"],
327 Cause.NoSuchElementError | Schema.SchemaError | SqlError,
328 S["DecodingServices"] | S["fields"][Id]["EncodingServices"]
329 >
330 > = SqlResolver.findById({
331 Id: idSchema,
332 Result: Model,
333 ResultId(request: any) {
334 return request[idColumn]
335 },
336 execute: (ids: any) =>
337 sql`select * from ${sql(options.tableName)} where ${withSoftDeleteFilter(sql.in(idColumn, ids))}`
338 }).pipe(
339 RequestResolver.withSpan(`${options.spanPrefix}.findByIdResolver`)
340 )
341
342 const delete_: RequestResolver.RequestResolver<
343 SqlResolver.SqlRequest<
344 S["fields"][Id]["Type"],
345 void,
346 Schema.SchemaError | SqlError,
347 S["fields"][Id]["EncodingServices"]
348 >
349 > = SqlResolver.void({
350 Request: idSchema,
351 execute: (ids: any) =>
352 softDeleteColumn === undefined
353 ? sql`delete from ${sql(options.tableName)} where ${sql.in(idColumn, ids)}`
354 : sql`update ${sql(options.tableName)} set ${setSoftDeleted} where ${
355 withSoftDeleteFilter(sql.in(idColumn, ids))
356 }`
357 }).pipe(
358 RequestResolver.withSpan(`${options.spanPrefix}.deleteResolver`)
359 )
360
361 return { insert, insertVoid, findById, delete: delete_ } as const
362 })