kysely-hydrate 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -131,6 +131,7 @@ By design, Kysely has the following constraints:
131
131
  - [`.hasMany()`](#hasmany)
132
132
  - [`.hasOne()`](#hasone)
133
133
  - [`.hasOneOrThrow()`](#hasoneorthrow)
134
+ - [Hydrated lateral joins](#hydrated-lateral-joins)
134
135
  - [Application-level joins with `.attach*()`](#application-level-joins-with-attach)
135
136
  - [`.attachMany()`](#attachmany)
136
137
  - [`.attachOne()`](#attachone)
@@ -138,6 +139,7 @@ By design, Kysely has the following constraints:
138
139
  - [Mapped properties with `.mapFields()`](#mapped-properties-with-mapfields)
139
140
  - [Computed properties with `.extras()`](#computed-properties-with-extras)
140
141
  - [Excluded properties with `.omit()`](#excluded-properties-with-omit)
142
+ - [Output transformations with `.map()`](#output-transformations-with-map)
141
143
  - [Composable mappings with `.with()`](#composable-mappings-with-with)
142
144
  - [Execution](#execution)
143
145
  - [Hydrators](#hydrators)
@@ -146,6 +148,7 @@ By design, Kysely has the following constraints:
146
148
  - [Selecting and mapping fields with `.fields()`](#selecting-and-mapping-fields-with-fields)
147
149
  - [Computed properties with `.extras()`](#computed-properties-with-extras-1)
148
150
  - [Excluding fields with `.omit()`](#excluding-fields-with-omit)
151
+ - [Output transformations with `.map()`](#output-transformations-with-map-1)
149
152
  - [Attached collections with `.attach*()`](#attached-collections-with-attach)
150
153
  - [Prefixed collections with `.has*()`](#prefixed-collections-with-has)
151
154
  - [Composing hydrators with `.extend()`](#composing-hydrators-with-extend)
@@ -463,12 +466,86 @@ but you know the record must exist.
463
466
 
464
467
  > [!NOTE]
465
468
  > Like `hydrate()`, `hasOneOrThrow()` also accepts an optional final `keyBy` argument with the
466
- > same semantics (see [Keying and deduplication with `keyBy`](#keying-and-deduplication-with-keyby)).
469
+ > same semantics (see [Keying and deduplication with
470
+ > `keyBy`](#keying-and-deduplication-with-keyby)).
471
+
472
+ #### Hydrated lateral joins
473
+
474
+ Kysely supports lateral joins by passing a subquery to methods like
475
+ `innerJoinLateral`, `leftJoinLateral`, etc.
476
+
477
+ In addition to Kysely's default behavior, Kysely Hydrate also lets you pass an
478
+ **aliased hydrated subquery** to these methods. When you do, the subquery’s
479
+ selections are automatically added to the parent query (with the usual
480
+ prefixing), and the nested result is hydrated according to the nested hydration
481
+ config. This makes it possible to compose hydrated queries through lateral
482
+ joins.
483
+
484
+ Here’s an example selecting each user and their latest post (scoped per-user
485
+ inside the lateral subquery):
486
+
487
+ ```ts
488
+ const query = hydrate(db.selectFrom("users").select(["users.id", "users.username"]), "id")
489
+ .hasOne("latestPost", ({ innerJoinLateral }) =>
490
+ innerJoinLateral((db) =>
491
+ hydrate(
492
+ db
493
+ .selectFrom("posts")
494
+ .select(["posts.id", "posts.title"])
495
+ .whereRef("posts.user_id", "=", "users.id")
496
+ .orderBy("posts.id", "desc")
497
+ .limit(1),
498
+ "id",
499
+ ).as("latest_post_subquery"),
500
+ // You can omit `(join) => join.onTrue()` for the hydrated-subquery overload.
501
+ ),
502
+ );
503
+
504
+ const result = await query.execute();
505
+ // ⬇
506
+ type Result = Array<{
507
+ id: number;
508
+ username: string;
509
+ latestPost: { id: number; title: string };
510
+ }>;
511
+ ```
512
+
513
+ The hydrated lateral subquery is “hoisted” into the parent select list, and its
514
+ columns are prefixed under the relation key (e.g. `latestPost$$id`,
515
+ `latestPost$$title`) so the hydration layer can un-flatten them.
516
+
517
+ ```sql
518
+ select
519
+ "users"."id",
520
+ "users"."username",
521
+ "latest_post_subquery"."id" as "latestPost$$id",
522
+ "latest_post_subquery"."title" as "latestPost$$title"
523
+ from "users"
524
+ inner join lateral (
525
+ select "posts"."id", "posts"."title"
526
+ from "posts"
527
+ where "posts"."user_id" = "users"."id"
528
+ limit ?
529
+ ) as "latest_post_subquery" on true
530
+ ```
531
+
532
+ > [!WARNING]
533
+ > You cannot use `.selectAll()` in cases where Kysely Hydrate needs to resolve
534
+ > and rewrite nested selections for prefixing. Prefer explicit `.select([...])`
535
+ > (it will throw if selections can’t be resolved).
536
+
537
+ > [!WARNING]
538
+ > You're still writing SQL. Lateral joins are powerful, but it’s easy to build
539
+ > queries that don’t compose the way you expect (limits/order-by scopes,
540
+ > correlation, etc). When in doubt, inspect the output with
541
+ > `.toQuery().compile()`.
467
542
 
468
543
  #### SQL output
469
544
 
470
545
  Kysely Hydrate produces the SQL you tell it to with exactly one exception: it
471
- aliases nested selections to avoid naming collisions.
546
+ aliases nested selections to avoid naming collisions. (Kysely Hydrate also
547
+ hoists selections from hydrated lateral join subqueries, as described
548
+ [above](#hydrated-lateral-joins)—but only if you use that feature.)
472
549
 
473
550
  For example, `users.id` remains `users.id`, but `posts.id` nested under "posts"
474
551
  becomes `posts$$id`. The hydration layer then un-flattens these aliases back
@@ -779,6 +856,134 @@ const users = await hydrate(
779
856
  type Result = Array<{ id: number; fullName: string }>;
780
857
  ```
781
858
 
859
+ ### Output transformations with `.map()`
860
+
861
+ The `.map()` method transforms the hydrated output into a different shape. Use
862
+ it for complex transformations like:
863
+
864
+ - Converting plain objects into class instances
865
+ - Asserting discriminated union types
866
+ - Restructuring or reshaping data
867
+
868
+ Unlike `.mapFields()` and `.extras()`, which operate on individual fields,
869
+ `.map()` receives the complete hydrated result and can transform it however you
870
+ want.
871
+
872
+ ```ts
873
+ // Transform into class instances
874
+ class UserModel {
875
+ constructor(
876
+ public id: number,
877
+ public name: string,
878
+ ) {}
879
+
880
+ getDisplayName() {
881
+ return `User: ${this.name}`;
882
+ }
883
+ }
884
+
885
+ const users = await hydrate(
886
+ db.selectFrom("users").select(["users.id", "users.name"]),
887
+ )
888
+ .map((user) => new UserModel(user.id, user.name))
889
+ .execute();
890
+ // ⬇
891
+ type Result = UserModel[];
892
+ ```
893
+
894
+ #### Chaining transformations
895
+
896
+ You can chain multiple `.map()` calls to compose transformations. Each function
897
+ receives the output of the previous transformation.
898
+
899
+ ```ts
900
+ const users = await hydrate(
901
+ db.selectFrom("users").select(["users.id", "users.name"]),
902
+ )
903
+ .map((user) => ({ ...user, nameUpper: user.name.toUpperCase() }))
904
+ .map((user) => ({ id: user.id, display: user.nameUpper }))
905
+ .execute();
906
+ // ⬇
907
+ type Result = Array<{ id: number; display: string }>;
908
+ ```
909
+
910
+ #### Transforming nested collections
911
+
912
+ `.map()` works with nested collections too. You can apply transformations to
913
+ child entities and then to parents:
914
+
915
+ ```ts
916
+ const users = await hydrate(db.selectFrom("users").select(["users.id"]))
917
+ .hasMany("posts", ({ leftJoin }) =>
918
+ leftJoin("posts", "posts.userId", "users.id")
919
+ .select(["posts.id", "posts.title"])
920
+ // Transform child:
921
+ .map((post) => ({ postId: post.id, postTitle: post.title })),
922
+ )
923
+ // Transform parent:
924
+ .map((user) => ({
925
+ userId: user.id,
926
+ postCount: user.posts.length,
927
+ posts: user.posts,
928
+ }))
929
+ .execute();
930
+ // ⬇
931
+ type Result = Array<{
932
+ userId: number;
933
+ postCount: number;
934
+ posts: {
935
+ postId: number;
936
+ postTitle: string;
937
+ };
938
+ }>;
939
+ ```
940
+
941
+ #### Terminal operation
942
+
943
+ `.map()` is a **terminal operation**. After you call `.map()`, you can only:
944
+
945
+ - Call `.map()` again to chain another transformation
946
+ - Call `.modify()` to adjust the underlying SQL query
947
+ - Call execution methods (`.execute()`, `.executeTakeFirst()`, etc.)
948
+
949
+ You **cannot** call configuration methods like `.mapFields()`, `.extras()`,
950
+ `.hasMany()`, or `.with()` after `.map()`.
951
+
952
+ This is intentional: those methods would affect the *input* type expected by
953
+ your transformation function, which would break your mapping logic. By
954
+ preventing further configuration, the type system protects you from this class
955
+ of bugs.
956
+
957
+ ```ts
958
+ const mapped = hydrate(db.selectFrom("users").select(["users.id"]))
959
+ .map((user) => ({ userId: user.id }));
960
+
961
+ // ✅ These work:
962
+ mapped.map((data) => ({ transformed: data.userId }));
963
+ mapped.execute();
964
+
965
+ // ❌ These don't compile:
966
+ mapped.mapFields({ ... }); // Error: Property 'mapFields' does not exist
967
+ mapped.hasMany(...); // Error: Property 'hasMany' does not exist
968
+ ```
969
+
970
+ #### `.map()` vs `.mapFields()` and `.extras()`
971
+
972
+ When should you use `.map()` vs the more targeted methods?
973
+
974
+ - **Use `.mapFields()`** when you want to transform individual fields by name
975
+ (e.g., normalizing strings)
976
+ - **Use `.extras()`** when you want to add computed fields while keeping the
977
+ existing structure
978
+ - **Use `.map()`** when you need to:
979
+ - Convert to class instances
980
+ - Completely reshape the output
981
+ - Apply transformations that depend on the full hydrated result
982
+ - Assert or narrow types on the entire output shape
983
+
984
+ The targeted methods are more composable, because they can be interleaved with
985
+ joins, unlike `.map()`.
986
+
782
987
  ### Composable mappings with `.with()`
783
988
 
784
989
  Re-use hydration logic by importing it from another `Hydrator`. This is great for
@@ -948,20 +1153,16 @@ Computes new fields from the input row.
948
1153
  type UserRow = { id: number; username: string; email: string };
949
1154
 
950
1155
  const hydrator = createHydrator<UserRow>()
951
- .fields({ id: true, username: true, email: true })
952
1156
  .extras({
953
1157
  displayName: (u) => `${u.username} <${u.email}>`,
954
1158
  })
955
- .omit(["email"]);
956
1159
  // ⬇
957
- type Result = Array<{ id: number; username: string; displayName: string }>;
1160
+ type Result = Array<{ displayName: string }>;
958
1161
  ```
959
1162
 
960
1163
  ### Excluding fields with `.omit()`
961
1164
 
962
- Excludes fields from the output that were already included. This method
963
- primarily exists for use by the `HydratedQueryBuilder`, which includes all
964
- fields by default.
1165
+ Excludes fields from the output that were already included.
965
1166
 
966
1167
  ```ts
967
1168
  type UserRow = { id: number; passwordHash: string };
@@ -973,6 +1174,53 @@ const hydrator = createHydrator<UserRow>()
973
1174
  type Result = Array<{ id: number }>;
974
1175
  ```
975
1176
 
1177
+ This method primarily exists for use by the `HydratedQueryBuilder`, which
1178
+ includes all fields by default. It's not so useful in standalone Hydrators, in
1179
+ which you must explicitly name the fields to include. The example above is
1180
+ equivalent to `createHydrator<UserRow>().fields({ id: true })`.
1181
+
1182
+ ### Output transformations with `.map()`
1183
+
1184
+ The `.map()` method works the same way as described in the
1185
+ [`hydrate()` API section](#output-transformations-with-map) above: it
1186
+ transforms the hydrated output into a different shape, such as class instances
1187
+ or discriminated union types.
1188
+
1189
+ ```ts
1190
+ class UserModel {
1191
+ constructor(
1192
+ public id: number,
1193
+ public name: string,
1194
+ ) {}
1195
+ }
1196
+
1197
+ const hydrator = createHydrator<{ id: number; name: string }>()
1198
+ .fields({ id: true, name: true })
1199
+ .map((user) => new UserModel(user.id, user.name));
1200
+
1201
+ const users = await hydrateData(rows, hydrator);
1202
+ // ⬇
1203
+ type Result = UserModel[];
1204
+ ```
1205
+
1206
+ Like with `HydratedQueryBuilder`, `.map()` is a terminal operation—after
1207
+ calling it, you can only call `.map()` again or `.hydrate()`. You cannot call
1208
+ configuration methods like `.fields()`, `.extras()`, `.has*()`, or `.extend()`.
1209
+
1210
+ ```ts
1211
+ const mapped = createHydrator<User>()
1212
+ .fields({ id: true })
1213
+ .map((u) => ({ userId: u.id }));
1214
+
1215
+ // ✅ These work:
1216
+ mapped.map((data) => ({ transformed: data.userId }));
1217
+ mapped.hydrate(rows);
1218
+
1219
+ // ❌ These don't compile:
1220
+ mapped.fields({ ... }); // Error: Property 'fields' does not exist
1221
+ mapped.extend(...); // Error: Property 'extend' does not exist
1222
+ ```
1223
+
976
1224
  ### Attached collections with `.attach*()`
977
1225
 
978
1226
  These work the same as in the `hydrate()` API (see the `.attach*()` section above).
@@ -1041,9 +1289,7 @@ type UserRow = { id: number; username: string; email: string };
1041
1289
  const base = createHydrator<UserRow>().fields({ id: true, username: true });
1042
1290
 
1043
1291
  const withDisplayName = createHydrator<UserRow>()
1044
- .fields({ email: true })
1045
- .extras({ displayName: (u) => `${u.username} <${u.email}>` })
1046
- .omit(["email"]);
1292
+ .extras({ displayName: (u) => `${u.username} <${u.email}>` });
1047
1293
 
1048
1294
  const combined = base.extend(withDisplayName);
1049
1295
  // ⬇