diff --git a/packages/core/postgrest-js/src/PostgrestTransformBuilder.ts b/packages/core/postgrest-js/src/PostgrestTransformBuilder.ts index e37bd16662..b9be64ee69 100644 --- a/packages/core/postgrest-js/src/PostgrestTransformBuilder.ts +++ b/packages/core/postgrest-js/src/PostgrestTransformBuilder.ts @@ -4,6 +4,24 @@ import { GetResult } from './select-query-parser/result' import { CheckMatchingArrayTypes } from './types/types' import { ClientServerOptions, GenericSchema } from './types/common/common' import type { MaxAffectedEnabled } from './types/feature-flags' +import type { TablesAndViews } from './select-query-parser/types' + +/** + * Resolves the column type for `order()` when a known `referencedTable` is provided. + * + * - If `Column` is a valid column of the referenced table → accepts it (autocomplete works) + * - If `Column` is a wide `string` type (e.g. from a variable) → accepts it (dynamic usage) + * - If `Column` is an invalid string literal → constrains to valid columns (compile error) + */ +type OrderColumnForTable< + Schema extends GenericSchema, + ReferencedTable extends string, + Column extends string, +> = Column extends keyof TablesAndViews[ReferencedTable]['Row'] + ? Column + : string extends Column + ? string + : keyof TablesAndViews[ReferencedTable]['Row'] & string export default class PostgrestTransformBuilder< ClientOptions extends ClientServerOptions, @@ -147,7 +165,13 @@ export default class PostgrestTransformBuilder< * @param options.nullsFirst - If `true`, `null`s appear first. If `false`, * `null`s appear last. * @param options.referencedTable - Set this to order a referenced table by - * its columns + * its columns. When `referencedTable` matches a known table or view in the + * schema, column names are validated at compile time. If the embedded relation + * uses an alias (e.g. `.select('archived:messages(*)')`) and the alias does + * not coincide with a real table name, it falls through to the unchecked + * `string` overload. If the alias happens to match a different real table, + * columns will be validated against that unrelated table — use a plain + * `string` variable for the column in that case to bypass checking. * * @category Database * @subcategory Using modifiers @@ -156,9 +180,19 @@ export default class PostgrestTransformBuilder< column: ColumnName, options?: { ascending?: boolean; nullsFirst?: boolean; referencedTable?: undefined } ): this - order( + order, Column extends string>( + column: OrderColumnForTable, + options: { ascending?: boolean; nullsFirst?: boolean; referencedTable: ReferencedTable } + ): this + order( column: string, - options?: { ascending?: boolean; nullsFirst?: boolean; referencedTable?: string } + options?: { + ascending?: boolean + nullsFirst?: boolean + referencedTable?: ReferencedTable extends keyof TablesAndViews + ? never + : ReferencedTable + } ): this /** * @deprecated Use `options.referencedTable` instead of `options.foreignTable` @@ -170,9 +204,20 @@ export default class PostgrestTransformBuilder< /** * @deprecated Use `options.referencedTable` instead of `options.foreignTable` */ - order( + order, Column extends string>( + column: OrderColumnForTable, + options: { ascending?: boolean; nullsFirst?: boolean; foreignTable: ReferencedTable } + ): this + /** + * @deprecated Use `options.referencedTable` instead of `options.foreignTable` + */ + order( column: string, - options?: { ascending?: boolean; nullsFirst?: boolean; foreignTable?: string } + options?: { + ascending?: boolean + nullsFirst?: boolean + foreignTable?: ReferencedTable extends keyof TablesAndViews ? never : ReferencedTable + } ): this /** * Order the query result by `column`. diff --git a/packages/core/postgrest-js/test/index.test-d.ts b/packages/core/postgrest-js/test/index.test-d.ts index b8fed5a5ac..9e4b941635 100644 --- a/packages/core/postgrest-js/test/index.test-d.ts +++ b/packages/core/postgrest-js/test/index.test-d.ts @@ -10,27 +10,28 @@ const REST_URL = 'http://localhost:54321/rest/v1' const postgrest = new PostgrestClient(REST_URL) const postgrestWithOptions = new PostgrestClient(REST_URL) -type WithThrowOnError = T extends PostgrestFilterBuilder< - infer ClientOptions, - infer Schema, - infer Row, - infer Result, - infer RelationName, - infer Relationships, - infer Method, - boolean -> - ? PostgrestFilterBuilder< - ClientOptions, - Schema, - Row, - Result, - RelationName, - Relationships, - Method, - true - > - : never +type WithThrowOnError = + T extends PostgrestFilterBuilder< + infer ClientOptions, + infer Schema, + infer Row, + infer Result, + infer RelationName, + infer Relationships, + infer Method, + boolean + > + ? PostgrestFilterBuilder< + ClientOptions, + Schema, + Row, + Result, + RelationName, + Relationships, + Method, + true + > + : never // table and view name type safety { @@ -561,3 +562,80 @@ type WithThrowOnError = T extends PostgrestFilterBuilder< expectType>(true) } } + +// `.order()` with `referencedTable` provides typed column names from the referenced table (#971) +{ + // Base case: order by parent table columns still works + postgrest.from('users').select('messages(*)').order('username') + + // referencedTable provides autocomplete for the referenced table's columns + postgrest + .from('users') + .select('messages(*)') + .order('channel_id', { referencedTable: 'messages', ascending: false }) + + // Also works with other valid columns on the referenced table + postgrest + .from('users') + .select('messages(*)') + .order('message', { referencedTable: 'messages', ascending: false }) + + // Invalid column on a known referenced table should error + postgrest + .from('users') + .select('messages(*)') + // @ts-expect-error No overload matches this call. + .order('nonexistent_column', { referencedTable: 'messages', ascending: false }) + + // Deprecated foreignTable also provides typed column names + postgrest + .from('users') + .select('messages(*)') + .order('channel_id', { foreignTable: 'messages', ascending: false }) + + // Invalid column on a known referenced table should error (foreignTable) + postgrest + .from('users') + .select('messages(*)') + // @ts-expect-error No overload matches this call. + .order('nonexistent_column', { foreignTable: 'messages', ascending: false }) + + // Ordering by a column on 'channels' table works + postgrest + .from('messages') + .select('channels(*)') + .order('slug', { referencedTable: 'channels', ascending: true }) + + // Invalid column on channels should error + postgrest + .from('messages') + .select('channels(*)') + // @ts-expect-error No overload matches this call. + .order('bad_col', { referencedTable: 'channels', ascending: true }) + + // Dynamic (non-literal) column with a known referencedTable should compile + const sortColumn: string = 'channel_id' + postgrest + .from('users') + .select('messages(*)') + .order(sortColumn, { referencedTable: 'messages', ascending: false }) + + // Dynamic column with deprecated foreignTable should also compile + postgrest + .from('users') + .select('messages(*)') + .order(sortColumn, { foreignTable: 'messages', ascending: false }) + + // Aliased referencedTable that does NOT match a real table name + // falls through to the unchecked string overload (any column name accepted) + postgrest + .from('users') + .select('archived:messages(*)') + .order('anything_goes', { referencedTable: 'archived', ascending: false }) + + // Aliased referencedTable with deprecated foreignTable also falls through + postgrest + .from('users') + .select('archived:messages(*)') + .order('anything_goes', { foreignTable: 'archived', ascending: false }) +}