feat: expose unique columns and many-to-many markers in OpenAPI

Add unique constraint and many-to-many relationship metadata to the
generated OpenAPI spec so clients can render them.

- Store unique constraints on Table as tableUniqueCols (mirroring
  tablePKCols) instead of denormalizing them onto each Column.
- Compute unique constraints via a per-table tbl_unique_cols CTE in
  tablesSqlQuery.
- Annotate unique columns and composite unique constraints in property
  descriptions, and emit m2m markers in table descriptions.
This commit is contained in:
2026-08-20 18:03:43 +02:00
parent ce7ea53a57
commit 77ab8f83ac
8 changed files with 169 additions and 9 deletions
+33 -4
View File
@@ -30,8 +30,8 @@ import PostgREST.Network (escapeHostName)
import PostgREST.Query.OpenApi (TableAccess (..), TablesAccess)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Cardinality (..), Relationship (..),
RelationshipsMap)
import PostgREST.SchemaCache.Relationship (Cardinality (..), Junction (..),
Relationship (..), RelationshipsMap)
import PostgREST.SchemaCache.Routine (FuncVolatility (..), Routine (..),
RoutineParam (..))
import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap,
@@ -110,13 +110,35 @@ parseDefault colType colDefault =
makeTableDef :: RelationshipsMap -> (Table, TableAccess) -> (Text, Schema)
makeTableDef rels (t, access) =
(tn, (mempty :: Schema)
& description .~ tableDescription t
& description .~ tblDescription
& type_ ?~ SwaggerObject
& properties .~ fromList (makeProperty t rels <$> cols)
& required .~ fmap colName (filter (not . colNullable) cols))
where
tn = tableName t
cols = accessibleCols t (taSelectCols access)
tblDescription = case m2mMarkers t rels of
[] -> tableDescription t
ms -> Just $ maybe "" (`T.append` "\n\n") (tableDescription t) <> T.intercalate "\n" ms
-- | Emits markers for the many-to-many relationships of a table, so that clients
-- can render these relations. The marker includes the target table(embedding key),
-- the junction table and the junction columns referencing source and target.
m2mMarkers :: Table -> RelationshipsMap -> [Text]
m2mMarkers tbl rels = mapMaybe m2mMarker searchedRels
where
searchedRels = fromMaybe mempty $ HM.lookup (QualifiedIdentifier (tableSchema tbl) (tableName tbl), tableSchema tbl) rels
m2mMarker Relationship{relForeignTable, relCardinality=M2M junction} =
Just $ T.intercalate ""
[ "<m2m table='", qiName relForeignTable
, "' junction='", qiName (junTable junction)
, "' source='", junctionSourceCol junction
, "' target='", junctionTargetCol junction
, "'/>"
]
m2mMarker _ = Nothing
junctionSourceCol junction = maybe mempty snd (headMay $ junColsSource junction)
junctionTargetCol junction = maybe mempty snd (headMay $ junColsTarget junction)
accessibleCols :: Table -> [FieldName] -> [Column]
accessibleCols t cols = filter ((`elem` cols) . colName) (tableColumnsList t)
@@ -144,11 +166,18 @@ makeProperty tbl rels col = (colName col, Inline s)
(\(a, b) -> T.intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]) <$> fTblCol
pk :: Bool
pk = colName col `elem` tablePKCols tbl
uniqueNotes :: [Text]
uniqueNotes = mapMaybe uniqueNote (filter (colName col `elem`) (tableUniqueCols tbl))
where
uniqueNote cols
| length cols == 1 = Just "This is a Unique column.<unique/>"
| otherwise = Just $ "This is part of a composite unique constraint.<unique cols='" <> T.intercalate "," cols <> "'/>"
n = catMaybes
[ Just "Note:"
, if pk then Just "This is a Primary Key.<pk/>" else Nothing
, fk
]
<> uniqueNotes
<> catMaybes [fk]
d =
if length n > 1 then
Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n)
+28
View File
@@ -232,6 +232,7 @@ decodeTables =
<*> column HD.bool
<*> column HD.bool
<*> arrayColumn HD.text
<*> column (HD.refine parseUniqueCols HD.jsonb)
<*> parseCols (compositeArrayColumn
(Column
<$> compositeField HD.text
@@ -247,6 +248,12 @@ decodeTables =
parseCols :: HD.Row [Column] -> HD.Row ColumnMap
parseCols = fmap (HMI.fromList . map (\col@Column{colName} -> (colName, col)))
parseUniqueCols :: JSON.Value -> Either Text [[FieldName]]
parseUniqueCols val =
case JSON.fromJSON val of
JSON.Success cols -> Right cols
JSON.Error err -> Left ("Invalid unique columns: " <> T.pack err)
decodeRels :: HD.Result [Relationship]
decodeRels =
HD.rowList relRow
@@ -676,6 +683,25 @@ tablesSqlQuery pgVer =
AND NOT pg_is_other_temp_schema(r.relnamespace)
AND NOT a.attisdropped
GROUP BY r.oid
),
tbl_unique_cols AS (
SELECT
r.oid AS relid,
jsonb_agg(cols ORDER BY c.oid) AS unique_cols
FROM pg_class r
JOIN pg_constraint c
ON r.oid = c.conrelid
JOIN LATERAL (
SELECT jsonb_agg(a.attname::text ORDER BY k.ord) AS cols
FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = r.oid AND a.attnum = k.attnum
) col_info ON TRUE
WHERE
c.contype = 'u'
AND r.relkind IN ('r', 'p')
AND r.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
AND NOT pg_is_other_temp_schema(r.relnamespace)
GROUP BY r.oid
)
SELECT
n.nspname AS table_schema,
@@ -709,11 +735,13 @@ tablesSqlQuery pgVer =
)
) AS deletable,
coalesce(tpks.pk_cols, '{}') as pk_cols,
coalesce(tunq.unique_cols, '[]'::jsonb) as unique_cols,
coalesce(cols_agg.columns, '{}') as columns
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_description d on d.objoid = c.oid and d.objsubid = 0 and d.classoid = 'pg_class'::regclass
LEFT JOIN tbl_pk_cols tpks ON c.oid = tpks.relid
LEFT JOIN tbl_unique_cols tunq ON c.oid = tunq.relid
LEFT JOIN columns_agg cols_agg ON c.oid = cols_agg.relid
WHERE c.relkind IN ('v','r','m','f','p')
AND c.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
@@ -29,6 +29,10 @@ data Table = Table
, tableUpdatable :: Bool
, tableDeletable :: Bool
, tablePKCols :: [FieldName]
-- ^ Each element is the position-ordered column list of a unique
-- constraint. A single-column unique constraint is represented by a
-- single-element list.
, tableUniqueCols :: [[FieldName]]
, tableColumns :: ColumnMap
}
deriving (Show, Generic, JSON.ToJSON)