mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
fix(mssql foreign tables): disallow sort on columns filtered for equality (#40137)
There is an edge case interaction between the Postgres query parser and MSSQL foreign tables, where the query parser may drop sort clauses that are redundant with applied filters. This leads to invalid MSSQL syntax, because the resulting query has a `limit` but no `sort`, and the user sees a confusing error message. This PR detects this edge case on MSSQL foreign tables. There are three cases: 1. The user filters by a column, but there are still other columns available for sorting. The default search for a sorting column will leave out the filtered column. 2. The user filters by a column/several columns, and there are no more columns that can be used for sorting. We stop the query and show an admonition. 3. The user filters by a column, then tries to sort by the same column. We stop the query and show an admonition.
This commit is contained in:
1 parent
d2a4175bdb
commit
df63ce3658
21 files changed
+267
-60
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { isMsSqlForeignTable, type Entity } from 'data/table-editor/table-editor-types'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import type { Filter, Sort } from './types'
|
||||
|
||||
type ValidateMsSqlSortingParams = {
|
||||
filters: Filter[]
|
||||
sorts: Sort[]
|
||||
table: Entity
|
||||
}
|
||||
|
||||
type MsSqlWarning = 'ConflictingSort' | 'NoValidSortPossible'
|
||||
|
||||
/**
|
||||
* There is an edge case with the Postgres query planner and MS SQL foreign
|
||||
* tables, where the Postgres query planner will drop sort clauses that are
|
||||
* redundant with filters, resulting in invalid MS SQL syntax. We want to
|
||||
* detect any conflicting or impossible sorts on filtered columns when the
|
||||
* table is an MS SQL foreign table, and warn the user.
|
||||
*/
|
||||
export const validateMsSqlSorting = ({
|
||||
filters,
|
||||
sorts,
|
||||
table,
|
||||
}: ValidateMsSqlSortingParams):
|
||||
| { warning: MsSqlWarning; Component: ComponentType }
|
||||
| { warning: null } => {
|
||||
const isMsSql = isMsSqlForeignTable(table)
|
||||
if (!isMsSql) return { warning: null }
|
||||
|
||||
const equalityFilterColumns = new Set(
|
||||
filters
|
||||
.filter((filter) => filter.operator === '=' || filter.operator === 'is')
|
||||
.map((filter) => filter.column)
|
||||
)
|
||||
|
||||
const conflictingSort =
|
||||
sorts.length > 0 && sorts.every((sort) => equalityFilterColumns.has(sort.column))
|
||||
const showMsSqlSortWarning = equalityFilterColumns.size > 0 && !!conflictingSort
|
||||
if (showMsSqlSortWarning)
|
||||
return { warning: 'ConflictingSort', Component: MsSqlSortWarningAdmonition }
|
||||
|
||||
const noSortColumnsRemaining =
|
||||
table.columns.length > 0 &&
|
||||
table.columns.every((col) => col.data_type === 'json' || equalityFilterColumns.has(col.name))
|
||||
const showMsSqlNoValidSortWarning = filters.length > 0 && noSortColumnsRemaining
|
||||
if (showMsSqlNoValidSortWarning)
|
||||
return { warning: 'NoValidSortPossible', Component: MsSqlNoValidSortAdmonition }
|
||||
|
||||
return { warning: null }
|
||||
}
|
||||
|
||||
type MsSqlAdmonitionProps = {
|
||||
title: string
|
||||
children: string
|
||||
}
|
||||
|
||||
const MsSqlAdmonition = ({ title, children }: MsSqlAdmonitionProps): ReactNode => (
|
||||
<div className="mt-2 px-3 pb-2">
|
||||
<Admonition type="warning" title={title} description={children} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const MsSqlSortWarningAdmonition = (): ReactNode => (
|
||||
<MsSqlAdmonition title="Cannot sort by filtered column">
|
||||
Sorting only by columns filtered with "=" or "is" doesn't work on MSSQL tables. Pick a different
|
||||
sorting column, or add a column not in your filter.
|
||||
</MsSqlAdmonition>
|
||||
)
|
||||
|
||||
const MsSqlNoValidSortAdmonition = (): ReactNode => (
|
||||
<MsSqlAdmonition title="No valid sort column remaining">
|
||||
All columns that can be sorted have been filtered with "=" or "is", which doesn't work on MSSQL
|
||||
tables. Remove a column from your filter to continue.
|
||||
</MsSqlAdmonition>
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { isMsSqlForeignTable } from 'data/table-editor/table-editor-types'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { RoleImpersonationState } from 'lib/role-impersonation'
|
||||
import { EMPTY_ARR } from 'lib/void'
|
||||
@@ -22,6 +23,7 @@ import { GridProps } from './types'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useTableFilter } from './hooks/useTableFilter'
|
||||
import { useTableSort } from './hooks/useTableSort'
|
||||
import { validateMsSqlSorting } from './MsSqlValidation'
|
||||
|
||||
export const SupabaseGrid = ({
|
||||
customHeader,
|
||||
@@ -47,6 +49,11 @@ export const SupabaseGrid = ({
|
||||
|
||||
const roleImpersonationState = useRoleImpersonationStateSnapshot()
|
||||
|
||||
const msSqlWarning = isMsSqlForeignTable(snap.originalTable)
|
||||
? validateMsSqlSorting({ filters, sorts, table: snap.originalTable })
|
||||
: { warning: null }
|
||||
const tableQueriesEnabled = msSqlWarning.warning === null
|
||||
|
||||
const { data, error, isSuccess, isError, isLoading, isRefetching } = useTableRowsQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
@@ -60,6 +67,7 @@ export const SupabaseGrid = ({
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled: tableQueriesEnabled,
|
||||
retry: (_, error: any) => {
|
||||
const doesNotExistError = error && error.message?.includes('does not exist')
|
||||
if (doesNotExistError) onApplySorts([])
|
||||
@@ -77,7 +85,13 @@ export const SupabaseGrid = ({
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend} context={window}>
|
||||
<div className="sb-grid h-full flex flex-col">
|
||||
<Header customHeader={customHeader} isRefetching={isRefetching} />
|
||||
<Header
|
||||
customHeader={customHeader}
|
||||
isRefetching={isRefetching}
|
||||
tableQueriesEnabled={tableQueriesEnabled}
|
||||
/>
|
||||
|
||||
{msSqlWarning.warning !== null && <msSqlWarning.Component />}
|
||||
|
||||
{children || (
|
||||
<>
|
||||
@@ -86,13 +100,14 @@ export const SupabaseGrid = ({
|
||||
{...gridProps}
|
||||
rows={rows}
|
||||
error={error}
|
||||
isDisabled={!tableQueriesEnabled}
|
||||
isLoading={isLoading}
|
||||
isSuccess={isSuccess}
|
||||
isError={isError}
|
||||
filters={filters}
|
||||
onApplyFilters={onApplyFilters}
|
||||
/>
|
||||
<Footer />
|
||||
<Footer enableForeignRowsQuery={tableQueriesEnabled} />
|
||||
<Shortcuts gridRef={gridRef} rows={rows} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,11 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useUrlState } from 'hooks/ui/useUrlState'
|
||||
import { Pagination } from './pagination/Pagination'
|
||||
|
||||
export const Footer = () => {
|
||||
type FooterProps = {
|
||||
enableForeignRowsQuery?: boolean
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = ({ enableForeignRowsQuery = true }: FooterProps) => {
|
||||
const { id: _id } = useParams()
|
||||
const id = _id ? Number(_id) : undefined
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
@@ -33,7 +37,7 @@ export const Footer = () => {
|
||||
|
||||
return (
|
||||
<GridFooter>
|
||||
{selectedView === 'data' && <Pagination />}
|
||||
{selectedView === 'data' && <Pagination enableForeignRowsQuery={enableForeignRowsQuery} />}
|
||||
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
{(isViewSelected || isTableSelected) && (
|
||||
|
||||
@@ -47,7 +47,11 @@ const RowCountSelector = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const Pagination = () => {
|
||||
type PaginationProps = {
|
||||
enableForeignRowsQuery?: boolean
|
||||
}
|
||||
|
||||
export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) => {
|
||||
const { id: _id } = useParams()
|
||||
const id = _id ? Number(_id) : undefined
|
||||
|
||||
@@ -108,7 +112,7 @@ export const Pagination = () => {
|
||||
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
|
||||
},
|
||||
{
|
||||
enabled: isForeignTableSelected,
|
||||
enabled: isForeignTableSelected && enableForeignRowsQuery,
|
||||
}
|
||||
)
|
||||
const isLastPage = (rowsData?.rows ?? []).length < tableEditorSnap.rowsPerPage
|
||||
@@ -223,8 +227,8 @@ export const Pagination = () => {
|
||||
icon={<ArrowRight />}
|
||||
type="outline"
|
||||
className="px-1.5"
|
||||
disabled={isLastPage}
|
||||
loading={isLoadingRows}
|
||||
disabled={isLastPage || !enableForeignRowsQuery}
|
||||
loading={isLoadingRows && enableForeignRowsQuery}
|
||||
onClick={goToNextPage}
|
||||
/>
|
||||
<RowCountSelector onRowsPerPageChange={onRowsPerPageChange} />
|
||||
|
||||
@@ -26,6 +26,7 @@ const rowKeyGetter = (row: SupaRow) => {
|
||||
interface IGrid extends GridProps {
|
||||
rows: any[]
|
||||
error: any
|
||||
isDisabled?: boolean
|
||||
isLoading: boolean
|
||||
isSuccess: boolean
|
||||
isError: boolean
|
||||
@@ -45,6 +46,7 @@ export const Grid = memo(
|
||||
rowClass,
|
||||
rows,
|
||||
error,
|
||||
isDisabled = false,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
@@ -154,7 +156,7 @@ export const Grid = memo(
|
||||
onDragLeave={onDragOver}
|
||||
onDrop={onFileDrop}
|
||||
>
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
{isLoading && !isDisabled && <GenericSkeletonLoader />}
|
||||
|
||||
{isError && <GridError error={error} />}
|
||||
|
||||
|
||||
@@ -58,9 +58,10 @@ export const MAX_EXPORT_ROW_COUNT_MESSAGE = (
|
||||
export type HeaderProps = {
|
||||
customHeader: ReactNode
|
||||
isRefetching: boolean
|
||||
tableQueriesEnabled?: boolean
|
||||
}
|
||||
|
||||
export const Header = ({ customHeader, isRefetching }: HeaderProps) => {
|
||||
export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true }: HeaderProps) => {
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
|
||||
return (
|
||||
@@ -69,7 +70,7 @@ export const Header = ({ customHeader, isRefetching }: HeaderProps) => {
|
||||
{customHeader ? (
|
||||
customHeader
|
||||
) : snap.selectedRows.size > 0 ? (
|
||||
<RowHeader />
|
||||
<RowHeader tableQueriesEnabled={tableQueriesEnabled} />
|
||||
) : (
|
||||
<DefaultHeader />
|
||||
)}
|
||||
@@ -224,7 +225,12 @@ const DefaultHeader = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const RowHeader = () => {
|
||||
type RowHeaderProps = {
|
||||
tableQueriesEnabled?: boolean
|
||||
}
|
||||
|
||||
const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
debugger
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
@@ -238,16 +244,19 @@ const RowHeader = () => {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
|
||||
const { data } = useTableRowsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
tableId: snap.table.id,
|
||||
sorts,
|
||||
filters,
|
||||
page: snap.page,
|
||||
limit: tableEditorSnap.rowsPerPage,
|
||||
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
|
||||
})
|
||||
const { data } = useTableRowsQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
tableId: snap.table.id,
|
||||
sorts,
|
||||
filters,
|
||||
page: snap.page,
|
||||
limit: tableEditorSnap.rowsPerPage,
|
||||
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
|
||||
},
|
||||
{ enabled: tableQueriesEnabled }
|
||||
)
|
||||
|
||||
const { data: countData } = useTableRowsCountQuery(
|
||||
{
|
||||
@@ -258,7 +267,7 @@ const RowHeader = () => {
|
||||
enforceExactCount: snap.enforceExactCount,
|
||||
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
|
||||
},
|
||||
{ keepPreviousData: true }
|
||||
{ keepPreviousData: true, enabled: tableQueriesEnabled }
|
||||
)
|
||||
|
||||
const allRows = data?.rows ?? []
|
||||
|
||||
+1
-1
@@ -391,7 +391,7 @@ export const SidePanelEditor = ({
|
||||
payload: {
|
||||
name: string
|
||||
schema: string
|
||||
comment?: string | undefined
|
||||
comment?: string | null
|
||||
},
|
||||
columns: ColumnField[],
|
||||
foreignKeyRelations: ForeignKey[],
|
||||
|
||||
+2
-2
@@ -374,7 +374,7 @@ export const updateColumn = async ({
|
||||
export const duplicateTable = async (
|
||||
projectRef: string,
|
||||
connectionString: string | undefined | null,
|
||||
payload: { name: string; comment?: string },
|
||||
payload: { name: string; comment?: string | null },
|
||||
metadata: {
|
||||
duplicateTable: RetrieveTableResult
|
||||
isRLSEnabled: boolean
|
||||
@@ -470,7 +470,7 @@ export const createTable = async ({
|
||||
payload: {
|
||||
name: string
|
||||
schema: string
|
||||
comment?: string | undefined
|
||||
comment?: string | null
|
||||
}
|
||||
columns: ColumnField[]
|
||||
foreignKeyRelations: ForeignKey[]
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export interface TableEditorProps {
|
||||
payload: {
|
||||
name: string
|
||||
schema: string
|
||||
comment?: string | undefined
|
||||
comment?: string | null
|
||||
},
|
||||
columns: ColumnField[],
|
||||
foreignKeyRelations: ForeignKey[],
|
||||
|
||||
@@ -14,9 +14,16 @@ export function getTableEditorSql(id?: number) {
|
||||
c.relforcerowsecurity as rls_forced,
|
||||
c.relreplident,
|
||||
c.relowner,
|
||||
obj_description(c.oid) as comment
|
||||
obj_description(c.oid) as comment,
|
||||
fs.srvname as foreign_server_name,
|
||||
fdw.fdwname as foreign_data_wrapper_name,
|
||||
fdw_handler.proname as foreign_data_wrapper_handler
|
||||
from pg_class c
|
||||
join pg_namespace nc on nc.oid = c.relnamespace
|
||||
left join pg_foreign_table ft on ft.ftrelid = c.oid
|
||||
left join pg_foreign_server fs on fs.oid = ft.ftserver
|
||||
left join pg_foreign_data_wrapper fdw on fdw.oid = fs.srvfdw
|
||||
left join pg_proc fdw_handler on fdw.fdwhandler = fdw_handler.oid
|
||||
where c.oid = ${id}
|
||||
and not pg_is_other_temp_schema(nc.oid)
|
||||
and (
|
||||
@@ -252,6 +259,9 @@ export function getTableEditorSql(id?: number) {
|
||||
'schema', b.schema,
|
||||
'name', b.name,
|
||||
'comment', b.comment,
|
||||
'foreign_server_name', b.foreign_server_name,
|
||||
'foreign_data_wrapper_name', b.foreign_data_wrapper_name,
|
||||
'foreign_data_wrapper_handler', b.foreign_data_wrapper_handler,
|
||||
'columns', coalesce(c.columns, '[]'::jsonb)
|
||||
)
|
||||
end as entity
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
PostgresTable,
|
||||
PostgresView,
|
||||
} from '@supabase/postgres-meta'
|
||||
import { WRAPPER_HANDLERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants'
|
||||
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
|
||||
|
||||
interface TableRelationship extends PostgresRelationship {
|
||||
@@ -40,6 +41,9 @@ export interface ForeignTable {
|
||||
schema: string
|
||||
name: string
|
||||
comment: string | null
|
||||
foreign_server_name: string
|
||||
foreign_data_wrapper_name: string
|
||||
foreign_data_wrapper_handler: string
|
||||
columns: PostgresColumn[]
|
||||
}
|
||||
|
||||
@@ -67,6 +71,10 @@ export function isForeignTable(entity?: Entity): entity is ForeignTable {
|
||||
return entity?.entity_type === ENTITY_TYPE.FOREIGN_TABLE
|
||||
}
|
||||
|
||||
export function isMsSqlForeignTable(entity?: Entity): entity is ForeignTable {
|
||||
return isForeignTable(entity) && entity.foreign_data_wrapper_handler === WRAPPER_HANDLERS.MSSQL
|
||||
}
|
||||
|
||||
export function isView(entity?: Entity): entity is View {
|
||||
return entity?.entity_type === ENTITY_TYPE.VIEW
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IS_PLATFORM } from 'common'
|
||||
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
|
||||
import { Filter, Sort, SupaRow, SupaTable } from 'components/grid/types'
|
||||
import { prefetchTableEditor } from 'data/table-editor/table-editor-query'
|
||||
import { isMsSqlForeignTable } from 'data/table-editor/table-editor-types'
|
||||
import {
|
||||
ROLE_IMPERSONATION_NO_RESULTS,
|
||||
RoleImpersonationState,
|
||||
@@ -248,8 +249,27 @@ export async function getTableRows(
|
||||
|
||||
const table = parseSupaTable(entity)
|
||||
|
||||
const equalityFilterColumns = filters
|
||||
?.filter((filter) => filter.operator === '=' || filter.operator === 'is')
|
||||
.flatMap((filter) => filter.column)
|
||||
|
||||
// There is an edge case for MS SQL foreign tables, where the Postgres query
|
||||
// planner may drop sorts that are redundant with filters, resulting in
|
||||
// invalid MS SQL syntax. To prevent this, we exclude potentially conflicting
|
||||
// columns from potential default sort columns.
|
||||
const excludedColumns = isMsSqlForeignTable(entity)
|
||||
? Array.from(new Set(equalityFilterColumns))
|
||||
: undefined
|
||||
|
||||
const sql = wrapWithRoleImpersonation(
|
||||
getTableRowsSql({ table: entity, filters, sorts, limit, page }),
|
||||
getTableRowsSql({
|
||||
table: entity,
|
||||
filters,
|
||||
sorts,
|
||||
limit,
|
||||
page,
|
||||
sortExcludedColumns: excludedColumns,
|
||||
}),
|
||||
roleImpersonationState
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { tableKeys } from './keys'
|
||||
export type CreateTableBody = {
|
||||
name: string
|
||||
schema?: string
|
||||
comment?: string
|
||||
comment?: string | null
|
||||
}
|
||||
|
||||
export type TableCreateVariables = {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { literal, ident } from './pg-format'
|
||||
import { z } from 'zod'
|
||||
import { DEFAULT_SYSTEM_SCHEMAS } from './constants'
|
||||
import { filterByList, coalesceRowsToArray } from './helpers'
|
||||
import { FOREIGN_TABLES_SQL } from './sql/foreign-tables'
|
||||
import { COLUMNS_SQL } from './sql/columns'
|
||||
import { coalesceRowsToArray, filterByList } from './helpers'
|
||||
import { ident, literal } from './pg-format'
|
||||
import { pgColumnArrayZod } from './pg-meta-columns'
|
||||
import { COLUMNS_SQL } from './sql/columns'
|
||||
import { FOREIGN_TABLES_SQL } from './sql/foreign-tables'
|
||||
|
||||
export const pgForeignTableZod = z.object({
|
||||
id: z.number(),
|
||||
schema: z.string(),
|
||||
name: z.string(),
|
||||
comment: z.string().nullable(),
|
||||
foreign_server_name: z.string(),
|
||||
foreign_data_wrapper_name: z.string(),
|
||||
foreign_data_wrapper_handler: z.string(),
|
||||
columns: pgColumnArrayZod.optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import { coalesceRowsToArray, filterByList } from './helpers'
|
||||
import { TABLES_SQL } from './sql/tables'
|
||||
import { COLUMNS_SQL } from './sql/columns'
|
||||
import { DEFAULT_SYSTEM_SCHEMAS } from './constants'
|
||||
import { coalesceRowsToArray, filterByList } from './helpers'
|
||||
import { ident, literal } from './pg-format'
|
||||
import { pgColumnArrayZod } from './pg-meta-columns'
|
||||
import { COLUMNS_SQL } from './sql/columns'
|
||||
import { TABLES_SQL } from './sql/tables'
|
||||
|
||||
const pgTablePrimaryKeyZod = z.object({
|
||||
table_id: z.number(),
|
||||
@@ -139,15 +139,14 @@ const generateEnrichedTablesSql = ({ includeColumns }: { includeColumns?: boolea
|
||||
type TableCreateParams = {
|
||||
name: string
|
||||
schema?: string
|
||||
comment?: string
|
||||
comment?: string | null
|
||||
}
|
||||
|
||||
function create({ name, schema = 'public', comment }: TableCreateParams): { sql: string } {
|
||||
const tableSql = `CREATE TABLE ${ident(schema)}.${ident(name)} ();`
|
||||
const commentSql =
|
||||
comment === undefined
|
||||
? ''
|
||||
: `COMMENT ON TABLE ${ident(schema)}.${ident(name)} IS ${literal(comment)};`
|
||||
const commentSql = comment
|
||||
? `COMMENT ON TABLE ${ident(schema)}.${ident(name)} IS ${literal(comment)};`
|
||||
: ''
|
||||
const sql = `BEGIN; ${tableSql} ${commentSql} COMMIT;`
|
||||
return { sql }
|
||||
}
|
||||
@@ -160,7 +159,7 @@ type TableUpdateParams = {
|
||||
replica_identity?: 'DEFAULT' | 'INDEX' | 'FULL' | 'NOTHING'
|
||||
replica_identity_index?: string
|
||||
primary_keys?: Array<{ name: string }>
|
||||
comment?: string
|
||||
comment?: string | null
|
||||
}
|
||||
|
||||
function update(
|
||||
@@ -231,10 +230,9 @@ $$;
|
||||
.join(',')});`
|
||||
}
|
||||
}
|
||||
const commentSql =
|
||||
comment === undefined
|
||||
? ''
|
||||
: `COMMENT ON TABLE ${ident(old.schema)}.${ident(old.name)} IS ${literal(comment)};`
|
||||
const commentSql = !!comment
|
||||
? `COMMENT ON TABLE ${ident(old.schema)}.${ident(old.name)} IS ${literal(comment)};`
|
||||
: ''
|
||||
// nameSql must be last, right below schemaSql
|
||||
const sql = `
|
||||
BEGIN;
|
||||
@@ -250,4 +248,4 @@ COMMIT;`
|
||||
return { sql }
|
||||
}
|
||||
|
||||
export { list, retrieve, remove, create, update }
|
||||
export { create, list, remove, retrieve, update }
|
||||
@@ -21,6 +21,10 @@ export interface BuildTableRowsQueryArgs {
|
||||
page?: number
|
||||
maxCharacters?: number
|
||||
maxArraySize?: number
|
||||
/**
|
||||
* Columns that should not be used for default sorting
|
||||
*/
|
||||
sortExcludedColumns?: string[]
|
||||
}
|
||||
|
||||
// Text and JSON types that should be truncated
|
||||
@@ -71,13 +75,22 @@ const LARGE_COLUMNS_TYPES_SET = new Set(LARGE_COLUMNS_TYPES)
|
||||
export const THRESHOLD_COUNT = 100000
|
||||
|
||||
// Return the primary key columns if exists, otherwise return the first column to use as a default sort
|
||||
export const getDefaultOrderByColumns = (table: Pick<PGTable, 'primary_keys' | 'columns'>) => {
|
||||
export const getDefaultOrderByColumns = (
|
||||
table: Pick<PGTable, 'primary_keys' | 'columns'>,
|
||||
{ excludedColumns = [] }: { excludedColumns?: string[] } = {}
|
||||
) => {
|
||||
const primaryKeyColumns = table.primary_keys?.map((pk) => pk.name)
|
||||
if (primaryKeyColumns && primaryKeyColumns.length > 0) {
|
||||
if (
|
||||
primaryKeyColumns &&
|
||||
primaryKeyColumns.length > 0 &&
|
||||
!primaryKeyColumns.every((col) => excludedColumns.includes(col))
|
||||
) {
|
||||
return primaryKeyColumns
|
||||
}
|
||||
if (table.columns && table.columns.length > 0) {
|
||||
const eligibleColumnsForSorting = table.columns.filter((x) => !x.data_type.includes('json'))
|
||||
const eligibleColumnsForSorting = table.columns.filter(
|
||||
(x) => !x.data_type.includes('json') && !excludedColumns.includes(x.name)
|
||||
)
|
||||
if (eligibleColumnsForSorting.length > 0) return [eligibleColumnsForSorting[0].name]
|
||||
else return []
|
||||
}
|
||||
@@ -111,6 +124,7 @@ export const getTableRowsSql = ({
|
||||
limit,
|
||||
maxCharacters = MAX_CHARACTERS,
|
||||
maxArraySize = MAX_ARRAY_SIZE,
|
||||
sortExcludedColumns = [],
|
||||
}: BuildTableRowsQueryArgs) => {
|
||||
if (!table || !table.columns) return ``
|
||||
|
||||
@@ -129,11 +143,13 @@ export const getTableRowsSql = ({
|
||||
)
|
||||
})
|
||||
|
||||
// If sorts is empty and table row count is within threshold, use the primary key as the default sort
|
||||
// If sorts is empty and table row count is within threshold, use the primary key as the default sort.
|
||||
// Only apply for selections over a Table, not View, MaterializedViews, ...
|
||||
const liveRowCount = (table as PGTable).live_rows_estimate || 0
|
||||
if (sorts.length === 0 && liveRowCount <= THRESHOLD_COUNT && table.columns.length > 0) {
|
||||
const defaultOrderByColumns = getDefaultOrderByColumns(table as PGTable)
|
||||
const defaultOrderByColumns = getDefaultOrderByColumns(table as PGTable, {
|
||||
excludedColumns: sortExcludedColumns,
|
||||
})
|
||||
if (defaultOrderByColumns.length > 0) {
|
||||
defaultOrderByColumns.forEach((col) => {
|
||||
queryChains = queryChains.order(table.name, col)
|
||||
|
||||
@@ -3,10 +3,17 @@ select
|
||||
c.oid::int8 as id,
|
||||
n.nspname as schema,
|
||||
c.relname as name,
|
||||
obj_description(c.oid) as comment
|
||||
obj_description(c.oid) as comment,
|
||||
fs.srvname as foreign_server_name,
|
||||
fdw.fdwname as foreign_data_wrapper_name,
|
||||
handler.proname as foreign_data_wrapper_handler
|
||||
from
|
||||
pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
inner join pg_foreign_table ft on ft.ftrelid = c.oid
|
||||
inner join pg_foreign_server fs on fs.oid = ft.ftserver
|
||||
inner join pg_foreign_data_wrapper fdw on fdw.oid = fs.srvfdw
|
||||
inner join pg_proc handler on handler.oid = fdw.fdwhandler
|
||||
where
|
||||
c.relkind = 'f'
|
||||
`
|
||||
@@ -43,7 +43,7 @@ FROM
|
||||
join pg_class c on i.indrelid = c.oid
|
||||
join pg_namespace n on c.relnamespace = n.oid
|
||||
join pg_attribute a on a.attrelid = c.oid and a.attnum = any(i.indkey)
|
||||
where
|
||||
where
|
||||
i.indisprimary
|
||||
group by c.oid
|
||||
) as pk
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, beforeAll, afterAll } from 'vitest'
|
||||
import { afterAll, beforeAll, expect, test } from 'vitest'
|
||||
import pgMeta from '../src/index'
|
||||
import { createTestDatabase, cleanupRoot } from './db/utils'
|
||||
import { cleanupRoot, createTestDatabase } from './db/utils'
|
||||
|
||||
beforeAll(async () => {
|
||||
// Any global setup if needed
|
||||
@@ -98,6 +98,9 @@ withTestDatabase('list foreign tables', async ({ executeQuery }) => {
|
||||
},
|
||||
],
|
||||
"comment": null,
|
||||
"foreign_data_wrapper_handler": "postgres_fdw_handler",
|
||||
"foreign_data_wrapper_name": "postgres_fdw",
|
||||
"foreign_server_name": "foreign_server",
|
||||
"id": Any<Number>,
|
||||
"name": "foreign_table",
|
||||
"schema": "public",
|
||||
@@ -115,6 +118,9 @@ withTestDatabase('list foreign tables without columns', async ({ executeQuery })
|
||||
`
|
||||
{
|
||||
"comment": null,
|
||||
"foreign_data_wrapper_handler": "postgres_fdw_handler",
|
||||
"foreign_data_wrapper_name": "postgres_fdw",
|
||||
"foreign_server_name": "foreign_server",
|
||||
"id": Any<Number>,
|
||||
"name": "foreign_table",
|
||||
"schema": "public",
|
||||
@@ -199,6 +205,9 @@ withTestDatabase('retrieve foreign table by name', async ({ executeQuery }) => {
|
||||
},
|
||||
],
|
||||
"comment": null,
|
||||
"foreign_data_wrapper_handler": "postgres_fdw_handler",
|
||||
"foreign_data_wrapper_name": "postgres_fdw",
|
||||
"foreign_server_name": "foreign_server",
|
||||
"id": Any<Number>,
|
||||
"name": "foreign_table",
|
||||
"schema": "public",
|
||||
@@ -282,6 +291,9 @@ withTestDatabase('retrieve foreign table by id', async ({ executeQuery }) => {
|
||||
},
|
||||
],
|
||||
"comment": null,
|
||||
"foreign_data_wrapper_handler": "postgres_fdw_handler",
|
||||
"foreign_data_wrapper_name": "postgres_fdw",
|
||||
"foreign_server_name": "foreign_server",
|
||||
"id": Any<Number>,
|
||||
"name": "foreign_table",
|
||||
"schema": "public",
|
||||
|
||||
@@ -34,6 +34,29 @@ describe('Table Row Query', () => {
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('should exclude specified columns when determining default sort', () => {
|
||||
const table = {
|
||||
primary_keys: [{ name: 'id' }],
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
data_type: 'integer',
|
||||
format: 'int4',
|
||||
ordinal_position: 1,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
data_type: 'text',
|
||||
format: 'text',
|
||||
ordinal_position: 2,
|
||||
},
|
||||
],
|
||||
} as any
|
||||
|
||||
const result = getDefaultOrderByColumns(table, { excludedColumns: ['id'] })
|
||||
expect(result).toEqual(['name'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTableRowsSql', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, beforeAll, afterAll } from 'vitest'
|
||||
import { afterAll, beforeAll, expect, test } from 'vitest'
|
||||
import pgMeta from '../src/index'
|
||||
import { createTestDatabase, cleanupRoot } from './db/utils'
|
||||
import { cleanupRoot, createTestDatabase } from './db/utils'
|
||||
|
||||
beforeAll(async () => {
|
||||
// Any global setup if needed
|
||||
@@ -1427,7 +1427,6 @@ for (const testCase of tableCreationTests) {
|
||||
await testCase.beforeTest(executeQuery)
|
||||
}
|
||||
|
||||
//@ts-expect-error
|
||||
const { sql } = pgMeta.tables.create(testCase.input)
|
||||
await executeQuery(sql)
|
||||
|
||||
|
||||
Reference in new issue
Block a user