metal-orm 1.0.78 → 1.0.80

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.
@@ -0,0 +1,207 @@
1
+ import type { ColumnDef } from '../schema/column-types.js';
2
+ import type { JsonSchemaProperty, JsonSchemaType, JsonSchemaFormat } from './schema-types.js';
3
+
4
+ /**
5
+ * Maps SQL column types to OpenAPI JSON Schema types
6
+ */
7
+ export const mapColumnType = (column: ColumnDef): JsonSchemaProperty => {
8
+ const sqlType = normalizeType(column.type);
9
+ const baseSchema = mapSqlTypeToBaseSchema(sqlType, column);
10
+
11
+ const schema: JsonSchemaProperty = {
12
+ ...baseSchema,
13
+ description: column.comment,
14
+ nullable: !column.notNull && !column.primary,
15
+ };
16
+
17
+ if (column.args && sqlType === 'varchar' || sqlType === 'char') {
18
+ schema.maxLength = column.args[0] as number | undefined;
19
+ }
20
+
21
+ if (column.args && sqlType === 'decimal' || sqlType === 'float') {
22
+ if (column.args.length >= 1) {
23
+ schema.minimum = -(10 ** (column.args[0] as number));
24
+ }
25
+ }
26
+
27
+ if (sqlType === 'enum' && column.args && column.args.length > 0) {
28
+ schema.enum = column.args as (string | number | boolean)[];
29
+ }
30
+
31
+ if (column.default !== undefined) {
32
+ schema.default = column.default;
33
+ }
34
+
35
+ return schema;
36
+ };
37
+
38
+ const normalizeType = (type: string): string => {
39
+ return type.toLowerCase();
40
+ };
41
+
42
+ const mapSqlTypeToBaseSchema = (
43
+ sqlType: string,
44
+ column: ColumnDef
45
+ ): Omit<JsonSchemaProperty, 'nullable' | 'description'> => {
46
+ const type = normalizeType(sqlType);
47
+
48
+ const hasCustomTsType = column.tsType !== undefined;
49
+
50
+ switch (type) {
51
+ case 'int':
52
+ case 'integer':
53
+ case 'bigint':
54
+ return {
55
+ type: hasCustomTsType ? inferTypeFromTsType(column.tsType) : ('integer' as JsonSchemaType),
56
+ format: type === 'bigint' ? 'int64' : 'int32',
57
+ minimum: column.autoIncrement ? 1 : undefined,
58
+ };
59
+
60
+ case 'decimal':
61
+ case 'float':
62
+ case 'double':
63
+ return {
64
+ type: hasCustomTsType ? inferTypeFromTsType(column.tsType) : ('number' as JsonSchemaType),
65
+ };
66
+
67
+ case 'varchar':
68
+ return {
69
+ type: 'string' as JsonSchemaType,
70
+ minLength: column.notNull ? 1 : undefined,
71
+ maxLength: column.args?.[0] as number | undefined,
72
+ };
73
+
74
+ case 'text':
75
+ return {
76
+ type: 'string' as JsonSchemaType,
77
+ minLength: column.notNull ? 1 : undefined,
78
+ };
79
+
80
+ case 'char':
81
+ return {
82
+ type: 'string' as JsonSchemaType,
83
+ minLength: column.notNull ? column.args?.[0] as number || 1 : undefined,
84
+ maxLength: column.args?.[0] as number,
85
+ };
86
+
87
+ case 'boolean':
88
+ return {
89
+ type: 'boolean' as JsonSchemaType,
90
+ };
91
+
92
+ case 'json':
93
+ return {
94
+ anyOf: [
95
+ { type: 'object' as JsonSchemaType },
96
+ { type: 'array' as JsonSchemaType },
97
+ ],
98
+ };
99
+
100
+ case 'blob':
101
+ case 'binary':
102
+ case 'varbinary':
103
+ return {
104
+ type: 'string' as JsonSchemaType,
105
+ format: 'base64' as JsonSchemaFormat,
106
+ };
107
+
108
+ case 'date':
109
+ return {
110
+ type: 'string' as JsonSchemaType,
111
+ format: 'date' as JsonSchemaFormat,
112
+ };
113
+
114
+ case 'datetime':
115
+ case 'timestamp':
116
+ return {
117
+ type: 'string' as JsonSchemaType,
118
+ format: 'date-time' as JsonSchemaFormat,
119
+ };
120
+
121
+ case 'timestamptz':
122
+ return {
123
+ type: 'string' as JsonSchemaType,
124
+ format: 'date-time' as JsonSchemaFormat,
125
+ };
126
+
127
+ case 'uuid':
128
+ return {
129
+ type: 'string' as JsonSchemaType,
130
+ format: 'uuid' as JsonSchemaFormat,
131
+ pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
132
+ };
133
+
134
+ case 'enum':
135
+ return {
136
+ type: 'string' as JsonSchemaType,
137
+ enum: (column.args as (string | number | boolean)[]) || [],
138
+ };
139
+
140
+ default:
141
+ if (column.dialectTypes?.postgres && column.dialectTypes.postgres === 'bytea') {
142
+ return {
143
+ type: 'string' as JsonSchemaType,
144
+ format: 'base64' as JsonSchemaFormat,
145
+ };
146
+ }
147
+
148
+ return {
149
+ type: 'string' as JsonSchemaType,
150
+ };
151
+ }
152
+ };
153
+
154
+ const inferTypeFromTsType = (tsType: unknown): JsonSchemaType => {
155
+ if (typeof tsType === 'string') {
156
+ if (tsType === 'number') return 'number' as JsonSchemaType;
157
+ if (tsType === 'string') return 'string' as JsonSchemaType;
158
+ if (tsType === 'boolean') return 'boolean' as JsonSchemaType;
159
+ }
160
+
161
+ if (typeof tsType === 'function') {
162
+ const typeStr = tsType.name?.toLowerCase();
163
+ if (typeStr === 'number') return 'number' as JsonSchemaType;
164
+ if (typeStr === 'string') return 'string' as JsonSchemaType;
165
+ if (typeStr === 'boolean') return 'boolean' as JsonSchemaType;
166
+ if (typeStr === 'array') return 'array' as JsonSchemaType;
167
+ if (typeStr === 'object') return 'object' as JsonSchemaType;
168
+ }
169
+
170
+ return 'string' as JsonSchemaType;
171
+ };
172
+
173
+ /**
174
+ * Maps relation type to array or single object
175
+ */
176
+ export const mapRelationType = (
177
+ relationType: string
178
+ ): { type: 'object' | 'array'; isNullable: boolean } => {
179
+ switch (relationType) {
180
+ case 'HAS_MANY':
181
+ case 'BELONGS_TO_MANY':
182
+ return { type: 'array', isNullable: false };
183
+ case 'HAS_ONE':
184
+ case 'BELONGS_TO':
185
+ return { type: 'object', isNullable: true };
186
+ default:
187
+ return { type: 'object', isNullable: true };
188
+ }
189
+ };
190
+
191
+ /**
192
+ * Gets the OpenAPI format for temporal columns
193
+ */
194
+ export const getTemporalFormat = (sqlType: string): JsonSchemaFormat | undefined => {
195
+ const type = normalizeType(sqlType);
196
+
197
+ switch (type) {
198
+ case 'date':
199
+ return 'date' as JsonSchemaFormat;
200
+ case 'datetime':
201
+ case 'timestamp':
202
+ case 'timestamptz':
203
+ return 'date-time' as JsonSchemaFormat;
204
+ default:
205
+ return undefined;
206
+ }
207
+ };
@@ -73,43 +73,41 @@ export async function executeCount(
73
73
  if (typeof value === 'number') return value;
74
74
  if (typeof value === 'bigint') return Number(value);
75
75
  if (typeof value === 'string') return Number(value);
76
- return value === null || value === undefined ? 0 : Number(value);
77
- }
78
-
79
- export interface PaginatedResult<T> {
80
- items: T[];
81
- totalItems: number;
82
- page: number;
83
- pageSize: number;
84
- }
85
-
86
- /**
87
- * Executes paged queries using the provided builder helpers.
88
- */
89
- export async function executePagedQuery<T, TTable extends TableDef>(
90
- builder: SelectQueryBuilder<T, TTable>,
91
- session: OrmSession,
92
- options: { page: number; pageSize: number },
93
- countCallback: (session: OrmSession) => Promise<number>
94
- ): Promise<PaginatedResult<T>> {
95
- const { page, pageSize } = options;
96
-
97
- if (!Number.isInteger(page) || page < 1) {
98
- throw new Error('executePaged: page must be an integer >= 1');
99
- }
100
- if (!Number.isInteger(pageSize) || pageSize < 1) {
101
- throw new Error('executePaged: pageSize must be an integer >= 1');
102
- }
103
-
104
- const offset = (page - 1) * pageSize;
105
-
106
- const [items, totalItems] = await Promise.all([
107
- builder.limit(pageSize).offset(offset).execute(session),
108
- countCallback(session)
109
- ]);
110
-
111
- return { items, totalItems, page, pageSize };
112
- }
76
+ return value === null || value === undefined ? 0 : Number(value);
77
+ }
78
+
79
+ export interface PaginatedResult<T> {
80
+ items: T[];
81
+ totalItems: number;
82
+ page: number;
83
+ pageSize: number;
84
+ }
85
+
86
+ /**
87
+ * Executes paged queries using the provided builder helpers.
88
+ */
89
+ export async function executePagedQuery<T, TTable extends TableDef>(
90
+ builder: SelectQueryBuilder<T, TTable>,
91
+ session: OrmSession,
92
+ options: { page: number; pageSize: number },
93
+ countCallback: (session: OrmSession) => Promise<number>
94
+ ): Promise<PaginatedResult<T>> {
95
+ const { page, pageSize } = options;
96
+
97
+ if (!Number.isInteger(page) || page < 1) {
98
+ throw new Error('executePaged: page must be an integer >= 1');
99
+ }
100
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
101
+ throw new Error('executePaged: pageSize must be an integer >= 1');
102
+ }
103
+
104
+ const offset = (page - 1) * pageSize;
105
+
106
+ const totalItems = await countCallback(session);
107
+ const items = await builder.limit(pageSize).offset(offset).execute(session);
108
+
109
+ return { items, totalItems, page, pageSize };
110
+ }
113
111
 
114
112
  /**
115
113
  * Builds an EXISTS or NOT EXISTS predicate for a related table.
@@ -29,17 +29,17 @@ import {
29
29
  SelectQueryBuilderEnvironment
30
30
  } from './select-query-builder-deps.js';
31
31
  import { ColumnSelector } from './column-selector.js';
32
- import { RelationIncludeOptions, RelationTargetColumns, TypedRelationIncludeOptions } from './relation-types.js';
33
- import { RelationKinds } from '../schema/relation.js';
34
- import {
35
- RelationIncludeInput,
36
- RelationIncludeNodeInput,
37
- NormalizedRelationIncludeTree,
38
- cloneRelationIncludeTree,
39
- mergeRelationIncludeTrees,
40
- normalizeRelationInclude,
41
- normalizeRelationIncludeNode
42
- } from './relation-include-tree.js';
32
+ import { RelationIncludeOptions, RelationTargetColumns, TypedRelationIncludeOptions } from './relation-types.js';
33
+ import { RelationKinds } from '../schema/relation.js';
34
+ import {
35
+ RelationIncludeInput,
36
+ RelationIncludeNodeInput,
37
+ NormalizedRelationIncludeTree,
38
+ cloneRelationIncludeTree,
39
+ mergeRelationIncludeTrees,
40
+ normalizeRelationInclude,
41
+ normalizeRelationIncludeNode
42
+ } from './relation-include-tree.js';
43
43
  import { JOIN_KINDS, JoinKind, ORDER_DIRECTIONS, OrderDirection } from '../core/sql/sql.js';
44
44
  import { EntityInstance, RelationMap } from '../schema/types.js';
45
45
  import type { ColumnToTs, InferRow } from '../schema/types.js';
@@ -50,23 +50,24 @@ import { executeHydrated, executeHydratedPlain, executeHydratedWithContexts } fr
50
50
  import { EntityConstructor } from '../orm/entity-metadata.js';
51
51
  import { materializeAs } from '../orm/entity-materializer.js';
52
52
  import { resolveSelectQuery } from './query-resolution.js';
53
- import {
54
- applyOrderBy,
55
- buildWhereHasPredicate,
56
- executeCount,
57
- executePagedQuery,
58
- PaginatedResult,
59
- RelationCallback,
60
- WhereHasOptions
61
- } from './select/select-operations.js';
62
- export type { PaginatedResult };
53
+ import {
54
+ applyOrderBy,
55
+ buildWhereHasPredicate,
56
+ executeCount,
57
+ executePagedQuery,
58
+ PaginatedResult,
59
+ RelationCallback,
60
+ WhereHasOptions
61
+ } from './select/select-operations.js';
62
+ export type { PaginatedResult };
63
63
  import { SelectFromFacet } from './select/from-facet.js';
64
64
  import { SelectJoinFacet } from './select/join-facet.js';
65
65
  import { SelectProjectionFacet } from './select/projection-facet.js';
66
66
  import { SelectPredicateFacet } from './select/predicate-facet.js';
67
67
  import { SelectCTEFacet } from './select/cte-facet.js';
68
68
  import { SelectSetOpFacet } from './select/setop-facet.js';
69
- import { SelectRelationFacet } from './select/relation-facet.js';
69
+ import { SelectRelationFacet } from './select/relation-facet.js';
70
+ import { extractSchema, SchemaOptions, OpenApiSchema } from '../openapi/index.js';
70
71
 
71
72
  type ColumnSelectionValue =
72
73
  | ColumnDef
@@ -115,11 +116,11 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
115
116
  private readonly predicateFacet: SelectPredicateFacet;
116
117
  private readonly cteFacet: SelectCTEFacet;
117
118
  private readonly setOpFacet: SelectSetOpFacet;
118
- private readonly relationFacet: SelectRelationFacet;
119
- private readonly lazyRelations: Set<string>;
120
- private readonly lazyRelationOptions: Map<string, RelationIncludeOptions>;
121
- private readonly entityConstructor?: EntityConstructor;
122
- private readonly includeTree: NormalizedRelationIncludeTree;
119
+ private readonly relationFacet: SelectRelationFacet;
120
+ private readonly lazyRelations: Set<string>;
121
+ private readonly lazyRelationOptions: Map<string, RelationIncludeOptions>;
122
+ private readonly entityConstructor?: EntityConstructor;
123
+ private readonly includeTree: NormalizedRelationIncludeTree;
123
124
 
124
125
  /**
125
126
  * Creates a new SelectQueryBuilder instance
@@ -128,16 +129,16 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
128
129
  * @param hydration - Optional hydration manager
129
130
  * @param dependencies - Optional query builder dependencies
130
131
  */
131
- constructor(
132
- table: TTable,
133
- state?: SelectQueryState,
134
- hydration?: HydrationManager,
135
- dependencies?: Partial<SelectQueryBuilderDependencies>,
136
- lazyRelations?: Set<string>,
137
- lazyRelationOptions?: Map<string, RelationIncludeOptions>,
138
- entityConstructor?: EntityConstructor,
139
- includeTree?: NormalizedRelationIncludeTree
140
- ) {
132
+ constructor(
133
+ table: TTable,
134
+ state?: SelectQueryState,
135
+ hydration?: HydrationManager,
136
+ dependencies?: Partial<SelectQueryBuilderDependencies>,
137
+ lazyRelations?: Set<string>,
138
+ lazyRelationOptions?: Map<string, RelationIncludeOptions>,
139
+ entityConstructor?: EntityConstructor,
140
+ includeTree?: NormalizedRelationIncludeTree
141
+ ) {
141
142
  const deps = resolveSelectQueryBuilderDependencies(dependencies);
142
143
  this.env = { table, deps };
143
144
  const createAstService = (nextState: SelectQueryState) => deps.createQueryAstService(table, nextState);
@@ -147,11 +148,11 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
147
148
  state: initialState,
148
149
  hydration: initialHydration
149
150
  };
150
- this.lazyRelations = new Set(lazyRelations ?? []);
151
- this.lazyRelationOptions = new Map(lazyRelationOptions ?? []);
152
- this.entityConstructor = entityConstructor;
153
- this.includeTree = includeTree ?? {};
154
- this.columnSelector = deps.createColumnSelector(this.env);
151
+ this.lazyRelations = new Set(lazyRelations ?? []);
152
+ this.lazyRelationOptions = new Map(lazyRelationOptions ?? []);
153
+ this.entityConstructor = entityConstructor;
154
+ this.includeTree = includeTree ?? {};
155
+ this.columnSelector = deps.createColumnSelector(this.env);
155
156
  const relationManager = deps.createRelationManager(this.env);
156
157
  this.fromFacet = new SelectFromFacet(this.env, createAstService);
157
158
  this.joinFacet = new SelectJoinFacet(this.env, createAstService);
@@ -168,23 +169,23 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
168
169
  * @param lazyRelations - Updated lazy relations set
169
170
  * @returns New SelectQueryBuilder instance
170
171
  */
171
- private clone<TNext = T>(
172
- context: SelectQueryBuilderContext = this.context,
173
- lazyRelations = new Set(this.lazyRelations),
174
- lazyRelationOptions = new Map(this.lazyRelationOptions),
175
- includeTree = this.includeTree
176
- ): SelectQueryBuilder<TNext, TTable> {
177
- return new SelectQueryBuilder(
178
- this.env.table as TTable,
179
- context.state,
180
- context.hydration,
181
- this.env.deps,
182
- lazyRelations,
183
- lazyRelationOptions,
184
- this.entityConstructor,
185
- includeTree
186
- ) as SelectQueryBuilder<TNext, TTable>;
187
- }
172
+ private clone<TNext = T>(
173
+ context: SelectQueryBuilderContext = this.context,
174
+ lazyRelations = new Set(this.lazyRelations),
175
+ lazyRelationOptions = new Map(this.lazyRelationOptions),
176
+ includeTree = this.includeTree
177
+ ): SelectQueryBuilder<TNext, TTable> {
178
+ return new SelectQueryBuilder(
179
+ this.env.table as TTable,
180
+ context.state,
181
+ context.hydration,
182
+ this.env.deps,
183
+ lazyRelations,
184
+ lazyRelationOptions,
185
+ this.entityConstructor,
186
+ includeTree
187
+ ) as SelectQueryBuilder<TNext, TTable>;
188
+ }
188
189
 
189
190
  /**
190
191
  * Applies an alias to the root FROM table.
@@ -564,42 +565,42 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
564
565
  * qb.include('posts');
565
566
  * @example
566
567
  * qb.include('posts', { columns: ['id', 'title', 'published'] });
567
- * @example
568
- * qb.include('posts', {
569
- * columns: ['id', 'title'],
570
- * where: eq(postTable.columns.published, true)
571
- * });
572
- * @example
573
- * qb.include({ posts: { include: { author: true } } });
574
- */
575
- include<K extends keyof TTable['relations'] & string>(
576
- relationName: K,
577
- options?: RelationIncludeNodeInput<TTable['relations'][K]>
578
- ): SelectQueryBuilder<T, TTable>;
579
- include(relations: RelationIncludeInput<TTable>): SelectQueryBuilder<T, TTable>;
580
- include<K extends keyof TTable['relations'] & string>(
581
- relationNameOrRelations: K | RelationIncludeInput<TTable>,
582
- options?: RelationIncludeNodeInput<TTable['relations'][K]>
583
- ): SelectQueryBuilder<T, TTable> {
584
- if (typeof relationNameOrRelations === 'object' && relationNameOrRelations !== null) {
585
- const normalized = normalizeRelationInclude(relationNameOrRelations as RelationIncludeInput<TableDef>);
586
- let nextContext = this.context;
587
- for (const [relationName, node] of Object.entries(normalized)) {
588
- nextContext = this.relationFacet.include(nextContext, relationName, node.options);
589
- }
590
- const nextTree = mergeRelationIncludeTrees(this.includeTree, normalized);
591
- return this.clone(nextContext, undefined, undefined, nextTree);
592
- }
593
-
594
- const relationName = relationNameOrRelations as string;
595
- const normalizedNode = normalizeRelationIncludeNode(options);
596
- const nextContext = this.relationFacet.include(this.context, relationName, normalizedNode.options);
597
- const shouldStore = Boolean(normalizedNode.include || normalizedNode.options);
598
- const nextTree = shouldStore
599
- ? mergeRelationIncludeTrees(this.includeTree, { [relationName]: normalizedNode })
600
- : this.includeTree;
601
- return this.clone(nextContext, undefined, undefined, nextTree);
602
- }
568
+ * @example
569
+ * qb.include('posts', {
570
+ * columns: ['id', 'title'],
571
+ * where: eq(postTable.columns.published, true)
572
+ * });
573
+ * @example
574
+ * qb.include({ posts: { include: { author: true } } });
575
+ */
576
+ include<K extends keyof TTable['relations'] & string>(
577
+ relationName: K,
578
+ options?: RelationIncludeNodeInput<TTable['relations'][K]>
579
+ ): SelectQueryBuilder<T, TTable>;
580
+ include(relations: RelationIncludeInput<TTable>): SelectQueryBuilder<T, TTable>;
581
+ include<K extends keyof TTable['relations'] & string>(
582
+ relationNameOrRelations: K | RelationIncludeInput<TTable>,
583
+ options?: RelationIncludeNodeInput<TTable['relations'][K]>
584
+ ): SelectQueryBuilder<T, TTable> {
585
+ if (typeof relationNameOrRelations === 'object' && relationNameOrRelations !== null) {
586
+ const normalized = normalizeRelationInclude(relationNameOrRelations as RelationIncludeInput<TableDef>);
587
+ let nextContext = this.context;
588
+ for (const [relationName, node] of Object.entries(normalized)) {
589
+ nextContext = this.relationFacet.include(nextContext, relationName, node.options);
590
+ }
591
+ const nextTree = mergeRelationIncludeTrees(this.includeTree, normalized);
592
+ return this.clone(nextContext, undefined, undefined, nextTree);
593
+ }
594
+
595
+ const relationName = relationNameOrRelations as string;
596
+ const normalizedNode = normalizeRelationIncludeNode(options);
597
+ const nextContext = this.relationFacet.include(this.context, relationName, normalizedNode.options);
598
+ const shouldStore = Boolean(normalizedNode.include || normalizedNode.options);
599
+ const nextTree = shouldStore
600
+ ? mergeRelationIncludeTrees(this.includeTree, { [relationName]: normalizedNode })
601
+ : this.includeTree;
602
+ return this.clone(nextContext, undefined, undefined, nextTree);
603
+ }
603
604
 
604
605
  /**
605
606
  * Includes a relation lazily in the query results
@@ -647,13 +648,13 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
647
648
  * @example
648
649
  * qb.includePick('posts', ['id', 'title', 'createdAt']);
649
650
  */
650
- includePick<
651
- K extends keyof TTable['relations'] & string,
652
- C extends RelationTargetColumns<TTable['relations'][K]>
653
- >(relationName: K, cols: C[]): SelectQueryBuilder<T, TTable> {
654
- const options = { columns: cols as readonly C[] } as unknown as RelationIncludeNodeInput<TTable['relations'][K]>;
655
- return this.include(relationName, options);
656
- }
651
+ includePick<
652
+ K extends keyof TTable['relations'] & string,
653
+ C extends RelationTargetColumns<TTable['relations'][K]>
654
+ >(relationName: K, cols: C[]): SelectQueryBuilder<T, TTable> {
655
+ const options = { columns: cols as readonly C[] } as unknown as RelationIncludeNodeInput<TTable['relations'][K]>;
656
+ return this.include(relationName, options);
657
+ }
657
658
 
658
659
  /**
659
660
  * Selects columns for the root table and relations from an array of entries
@@ -670,13 +671,13 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
670
671
  let currBuilder: SelectQueryBuilder<T, TTable> = this;
671
672
 
672
673
  for (const entry of config) {
673
- if (entry.type === 'root') {
674
- currBuilder = currBuilder.select(...entry.columns);
675
- } else {
676
- const options = { columns: entry.columns } as unknown as RelationIncludeNodeInput<TTable['relations'][typeof entry.relationName]>;
677
- currBuilder = currBuilder.include(entry.relationName, options);
678
- }
679
- }
674
+ if (entry.type === 'root') {
675
+ currBuilder = currBuilder.select(...entry.columns);
676
+ } else {
677
+ const options = { columns: entry.columns } as unknown as RelationIncludeNodeInput<TTable['relations'][typeof entry.relationName]>;
678
+ currBuilder = currBuilder.include(entry.relationName, options);
679
+ }
680
+ }
680
681
 
681
682
  return currBuilder;
682
683
  }
@@ -693,16 +694,16 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
693
694
  * Gets lazy relation include options
694
695
  * @returns Map of relation names to include options
695
696
  */
696
- getLazyRelationOptions(): Map<string, RelationIncludeOptions> {
697
- return new Map(this.lazyRelationOptions);
698
- }
699
-
700
- /**
701
- * Gets normalized nested include information for runtime preloading.
702
- */
703
- getIncludeTree(): NormalizedRelationIncludeTree {
704
- return cloneRelationIncludeTree(this.includeTree);
705
- }
697
+ getLazyRelationOptions(): Map<string, RelationIncludeOptions> {
698
+ return new Map(this.lazyRelationOptions);
699
+ }
700
+
701
+ /**
702
+ * Gets normalized nested include information for runtime preloading.
703
+ */
704
+ getIncludeTree(): NormalizedRelationIncludeTree {
705
+ return cloneRelationIncludeTree(this.includeTree);
706
+ }
706
707
 
707
708
  /**
708
709
  * Gets the table definition for this query builder
@@ -794,19 +795,19 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
794
795
  return executeCount(this.context, this.env, session);
795
796
  }
796
797
 
797
- /**
798
- * Executes the query and returns both the paged items and the total.
799
- *
800
- * @example
801
- * const { items, totalItems, page, pageSize } = await qb.executePaged(session, { page: 1, pageSize: 20 });
802
- */
803
- async executePaged(
804
- session: OrmSession,
805
- options: { page: number; pageSize: number }
806
- ): Promise<PaginatedResult<T>> {
807
- const builder = this.ensureDefaultSelection();
808
- return executePagedQuery(builder, session, options, sess => this.count(sess));
809
- }
798
+ /**
799
+ * Executes the query and returns both the paged items and the total.
800
+ *
801
+ * @example
802
+ * const { items, totalItems, page, pageSize } = await qb.executePaged(session, { page: 1, pageSize: 20 });
803
+ */
804
+ async executePaged(
805
+ session: OrmSession,
806
+ options: { page: number; pageSize: number }
807
+ ): Promise<PaginatedResult<T>> {
808
+ const builder = this.ensureDefaultSelection();
809
+ return executePagedQuery(builder, session, options, sess => builder.count(sess));
810
+ }
810
811
 
811
812
  /**
812
813
  * Executes the query with provided execution and hydration contexts
@@ -1108,16 +1109,29 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
1108
1109
  return this.compile(dialect).sql;
1109
1110
  }
1110
1111
 
1111
- /**
1112
- * Gets the hydration plan for the query
1113
- * @returns Hydration plan or undefined if none exists
1114
- * @example
1115
- * const plan = qb.include('posts').getHydrationPlan();
1116
- * console.log(plan?.relations); // Information about included relations
1117
- */
1118
- getHydrationPlan(): HydrationPlan | undefined {
1119
- return this.context.hydration.getPlan();
1120
- }
1112
+ /**
1113
+ * Gets hydration plan for query
1114
+ * @returns Hydration plan or undefined if none exists
1115
+ * @example
1116
+ * const plan = qb.include('posts').getHydrationPlan();
1117
+ * console.log(plan?.relations); // Information about included relations
1118
+ */
1119
+ getHydrationPlan(): HydrationPlan | undefined {
1120
+ return this.context.hydration.getPlan();
1121
+ }
1122
+
1123
+ /**
1124
+ * Gets OpenAPI 3.1 JSON Schema for query result
1125
+ * @param options - Schema generation options
1126
+ * @returns OpenAPI 3.1 JSON Schema for query result
1127
+ * @example
1128
+ * const schema = qb.select('id', 'title', 'author').getSchema();
1129
+ * console.log(JSON.stringify(schema, null, 2));
1130
+ */
1131
+ getSchema(options?: SchemaOptions): OpenApiSchema {
1132
+ const plan = this.context.hydration.getPlan();
1133
+ return extractSchema(this.env.table, plan, this.context.state.ast.columns, options);
1134
+ }
1121
1135
 
1122
1136
  /**
1123
1137
  * Gets the Abstract Syntax Tree (AST) representation of the query