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.Query.OpenApi (TableAccess (..), TablesAccess)
import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier (..)) import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Cardinality (..), Relationship (..), import PostgREST.SchemaCache.Relationship (Cardinality (..), Junction (..),
RelationshipsMap) Relationship (..), RelationshipsMap)
import PostgREST.SchemaCache.Routine (FuncVolatility (..), Routine (..), import PostgREST.SchemaCache.Routine (FuncVolatility (..), Routine (..),
RoutineParam (..)) RoutineParam (..))
import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap, import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap,
@@ -110,13 +110,35 @@ parseDefault colType colDefault =
makeTableDef :: RelationshipsMap -> (Table, TableAccess) -> (Text, Schema) makeTableDef :: RelationshipsMap -> (Table, TableAccess) -> (Text, Schema)
makeTableDef rels (t, access) = makeTableDef rels (t, access) =
(tn, (mempty :: Schema) (tn, (mempty :: Schema)
& description .~ tableDescription t & description .~ tblDescription
& type_ ?~ SwaggerObject & type_ ?~ SwaggerObject
& properties .~ fromList (makeProperty t rels <$> cols) & properties .~ fromList (makeProperty t rels <$> cols)
& required .~ fmap colName (filter (not . colNullable) cols)) & required .~ fmap colName (filter (not . colNullable) cols))
where where
tn = tableName t tn = tableName t
cols = accessibleCols t (taSelectCols access) 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 :: Table -> [FieldName] -> [Column]
accessibleCols t cols = filter ((`elem` cols) . colName) (tableColumnsList t) 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 (\(a, b) -> T.intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]) <$> fTblCol
pk :: Bool pk :: Bool
pk = colName col `elem` tablePKCols tbl 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 n = catMaybes
[ Just "Note:" [ Just "Note:"
, if pk then Just "This is a Primary Key.<pk/>" else Nothing , if pk then Just "This is a Primary Key.<pk/>" else Nothing
, fk
] ]
<> uniqueNotes
<> catMaybes [fk]
d = d =
if length n > 1 then if length n > 1 then
Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n) 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
<*> column HD.bool <*> column HD.bool
<*> arrayColumn HD.text <*> arrayColumn HD.text
<*> column (HD.refine parseUniqueCols HD.jsonb)
<*> parseCols (compositeArrayColumn <*> parseCols (compositeArrayColumn
(Column (Column
<$> compositeField HD.text <$> compositeField HD.text
@@ -247,6 +248,12 @@ decodeTables =
parseCols :: HD.Row [Column] -> HD.Row ColumnMap parseCols :: HD.Row [Column] -> HD.Row ColumnMap
parseCols = fmap (HMI.fromList . map (\col@Column{colName} -> (colName, col))) 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.Result [Relationship]
decodeRels = decodeRels =
HD.rowList relRow HD.rowList relRow
@@ -676,6 +683,25 @@ tablesSqlQuery pgVer =
AND NOT pg_is_other_temp_schema(r.relnamespace) AND NOT pg_is_other_temp_schema(r.relnamespace)
AND NOT a.attisdropped AND NOT a.attisdropped
GROUP BY r.oid 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 SELECT
n.nspname AS table_schema, n.nspname AS table_schema,
@@ -709,11 +735,13 @@ tablesSqlQuery pgVer =
) )
) AS deletable, ) AS deletable,
coalesce(tpks.pk_cols, '{}') as pk_cols, coalesce(tpks.pk_cols, '{}') as pk_cols,
coalesce(tunq.unique_cols, '[]'::jsonb) as unique_cols,
coalesce(cols_agg.columns, '{}') as columns coalesce(cols_agg.columns, '{}') as columns
FROM pg_class c FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace 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 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_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 LEFT JOIN columns_agg cols_agg ON c.oid = cols_agg.relid
WHERE c.relkind IN ('v','r','m','f','p') WHERE c.relkind IN ('v','r','m','f','p')
AND c.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace) AND c.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
@@ -29,6 +29,10 @@ data Table = Table
, tableUpdatable :: Bool , tableUpdatable :: Bool
, tableDeletable :: Bool , tableDeletable :: Bool
, tablePKCols :: [FieldName] , 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 , tableColumns :: ColumnMap
} }
deriving (Show, Generic, JSON.ToJSON) deriving (Show, Generic, JSON.ToJSON)
@@ -8,6 +8,7 @@
tableName: authors_only tableName: authors_only
tablePKCols: [] tablePKCols: []
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: cats - - qiName: cats
@@ -39,6 +40,7 @@
tablePKCols: tablePKCols:
- id - id
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: items_w_isolation_level - - qiName: items_w_isolation_level
@@ -69,6 +71,7 @@
tableName: items_w_isolation_level tableName: items_w_isolation_level
tablePKCols: [] tablePKCols: []
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: directors - - qiName: directors
@@ -100,6 +103,7 @@
tablePKCols: tablePKCols:
- id - id
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: projects - - qiName: projects
@@ -112,6 +116,7 @@
tableName: projects tableName: projects
tablePKCols: [] tablePKCols: []
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: infinite_recursion - - qiName: infinite_recursion
@@ -124,6 +129,7 @@
tableName: infinite_recursion tableName: infinite_recursion
tablePKCols: [] tablePKCols: []
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: false tableUpdatable: false
- - qiName: awards - - qiName: awards
@@ -182,6 +188,7 @@
tablePKCols: tablePKCols:
- id - id
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: films - - qiName: films
@@ -222,6 +229,7 @@
tablePKCols: tablePKCols:
- id - id
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: items - - qiName: items
@@ -243,4 +251,5 @@
tableName: items tableName: items
tablePKCols: [] tablePKCols: []
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
+1 -1
View File
@@ -239,7 +239,7 @@ def test_pool_acquisition_timeout(level, defaultenv, metapostgrest):
assert data["message"] == "Timed out acquiring connection from connection pool." assert data["message"] == "Timed out acquiring connection from connection pool."
# ensure the message appears on the logs as well # ensure the message appears on the logs as well
output = sorted(postgrest.read_stdout(nlines=10)) output = sorted(drain_stdout(postgrest))
if level == "crit": if level == "crit":
assert len(output) == 0 assert len(output) == 0
+3 -3
View File
@@ -44,7 +44,7 @@ def test_log_level(level, defaultenv):
response = postgrest.session.get("/") response = postgrest.session.get("/")
assert response.status_code == 200 assert response.status_code == 200
output = postgrest.read_stdout(nlines=9) output = drain_stdout(postgrest)
if level == "crit": if level == "crit":
assert len(output) == 0 assert len(output) == 0
@@ -82,7 +82,7 @@ def test_log_level(level, defaultenv):
r'- - postgrest_test_anonymous \[.+\] "GET / HTTP/1.1" 200 \d+ "" "python-requests/.+"', r'- - postgrest_test_anonymous \[.+\] "GET / HTTP/1.1" 200 \d+ "" "python-requests/.+"',
], ],
) )
assert len(output) == 9 assert len(output) > 3
assert any("Connection" and "is available" in line for line in output) assert any("Connection" and "is available" in line for line in output)
assert any("Connection" and "is used" in line for line in output) assert any("Connection" and "is used" in line for line in output)
@@ -403,7 +403,7 @@ def test_db_error_logging_to_stderr(level, defaultenv, metapostgrest):
assert response.status_code == 500 assert response.status_code == 500
# ensure the message appears on the logs # ensure the message appears on the logs
output = postgrest.read_stdout(nlines=8) output = drain_stdout(postgrest)
if level == "crit": if level == "crit":
assert len(output) == 0 assert len(output) == 0
+82 -1
View File
@@ -285,10 +285,91 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
{ {
"format": "int32", "format": "int32",
"type": "integer", "type": "integer",
"description": "Note:\nThis is a Foreign Key to `second.id`.<fk table='second' column='id'/>" "description": "Note:\nThis is a Unique column.<unique/>\nThis is a Foreign Key to `second.id`.<fk table='second' column='id'/>"
} }
|] |]
it "includes a unique description for a column with a unique constraint" $ do
r <- simpleBody <$> get "/"
let uniqueKey = r ^? key "definitions" . key "single_unique" . key "properties" . key "unique_key"
liftIO $
uniqueKey `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is a Unique column.<unique/>"
}
|]
it "includes the column list of a composite unique constraint" $ do
r <- simpleBody <$> get "/"
let compoundKey1 = r ^? key "definitions" . key "compound_unique" . key "properties" . key "key1"
compoundKey2 = r ^? key "definitions" . key "compound_unique" . key "properties" . key "key2"
liftIO $ do
compoundKey1 `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is part of a composite unique constraint.<unique cols='key1,key2'/>"
}
|]
compoundKey2 `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is part of a composite unique constraint.<unique cols='key1,key2'/>"
}
|]
it "includes the column list for mixed single and composite unique constraints" $ do
r <- simpleBody <$> get "/"
let uniqueCol = r ^? key "definitions" . key "mixed_unique" . key "properties" . key "id"
compoundKey1 = r ^? key "definitions" . key "mixed_unique" . key "properties" . key "key1"
compoundKey2 = r ^? key "definitions" . key "mixed_unique" . key "properties" . key "key2"
liftIO $ do
uniqueCol `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is a Unique column.<unique/>"
}
|]
compoundKey1 `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is part of a composite unique constraint.<unique cols='key1,key2'/>"
}
|]
compoundKey2 `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is part of a composite unique constraint.<unique cols='key1,key2'/>"
}
|]
it "includes m2m relationship markers in the table description" $ do
r <- simpleBody <$> get "/"
let beingDescription = r ^? key "definitions" . key "being" . key "description"
liftIO $
beingDescription `shouldBe` Just
[aesonQQ|"<m2m table='part' junction='being_part' source='being' target='part'/>"|]
describe "Foreign table" $ describe "Foreign table" $
it "includes foreign table properties" $ do it "includes foreign table properties" $ do
+9
View File
@@ -1451,6 +1451,15 @@ create table test.compound_unique(
unique(key1, key2) unique(key1, key2)
); );
create table test.mixed_unique(
id integer not null,
key1 integer not null,
key2 integer not null,
value text,
unique(id),
unique(key1, key2)
);
create table test.family_tree ( create table test.family_tree (
id text not null primary key, id text not null primary key,
name text not null, name text not null,