feat: expose composite foreign keys and m2m columns in OpenAPI
Replace the single-column `<fk table='...' column='...'/>` marker with a unified, composite-capable format `<fk table='...' columns='local:foreign,...'/>` and emit full column lists for many-to-many markers, so clients can reconstruct composite relationships. - Match every real table relationship a column participates in, instead of only single-column foreign keys, still preferring tables over the view-derived relationships. - Emit the full ordered local->foreign column mapping in the `<fk>` marker and the full source/target column lists in the `<m2m>` marker. - Add composite foreign key and composite m2m fixtures plus OpenAPI spec assertions.
This commit is contained in:
@@ -132,13 +132,13 @@ m2mMarkers tbl rels = mapMaybe m2mMarker searchedRels
|
|||||||
Just $ T.intercalate ""
|
Just $ T.intercalate ""
|
||||||
[ "<m2m table='", qiName relForeignTable
|
[ "<m2m table='", qiName relForeignTable
|
||||||
, "' junction='", qiName (junTable junction)
|
, "' junction='", qiName (junTable junction)
|
||||||
, "' source='", junctionSourceCol junction
|
, "' source='", junctionSourceCols junction
|
||||||
, "' target='", junctionTargetCol junction
|
, "' target='", junctionTargetCols junction
|
||||||
, "'/>"
|
, "'/>"
|
||||||
]
|
]
|
||||||
m2mMarker _ = Nothing
|
m2mMarker _ = Nothing
|
||||||
junctionSourceCol junction = maybe mempty snd (headMay $ junColsSource junction)
|
junctionSourceCols junction = T.intercalate "," (snd <$> junColsSource junction)
|
||||||
junctionTargetCol junction = maybe mempty snd (headMay $ junColsTarget junction)
|
junctionTargetCols junction = T.intercalate "," (snd <$> 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)
|
||||||
@@ -147,23 +147,32 @@ makeProperty :: Table -> RelationshipsMap -> Column -> (Text, Referenced Schema)
|
|||||||
makeProperty tbl rels col = (colName col, Inline s)
|
makeProperty tbl rels col = (colName col, Inline s)
|
||||||
where
|
where
|
||||||
e = if null $ colEnum col then Nothing else JSON.decode $ JSON.encode $ colEnum col
|
e = if null $ colEnum col then Nothing else JSON.decode $ JSON.encode $ colEnum col
|
||||||
fk :: Maybe Text
|
fks :: [Text]
|
||||||
fk =
|
fks =
|
||||||
let
|
let
|
||||||
searchedRels = fromMaybe mempty $ HM.lookup (QualifiedIdentifier (tableSchema tbl) (tableName tbl), tableSchema tbl) rels
|
searchedRels = fromMaybe mempty $ HM.lookup (QualifiedIdentifier (tableSchema tbl) (tableName tbl), tableSchema tbl) rels
|
||||||
-- Sorts the relationship list to get tables first
|
-- Sorts the relationship list to get tables first
|
||||||
relsSortedByIsView = sortOn relFTableIsView [ r | r@Relationship{} <- searchedRels]
|
relsSortedByIsView = sortOn relFTableIsView [ r | r@Relationship{} <- searchedRels]
|
||||||
-- Finds the relationship that has a single column foreign key
|
-- Finds the relationships that have this column among their foreign key columns
|
||||||
rel = find (\case
|
relsMatching = filter (\case
|
||||||
Relationship{relCardinality=(M2O _ relColumns)} -> [colName col] == (fst <$> relColumns)
|
Relationship{relCardinality=(M2O _ relColumns)} -> colName col `elem` (fst <$> relColumns)
|
||||||
Relationship{relCardinality=(O2O _ relColumns False)} -> [colName col] == (fst <$> relColumns)
|
Relationship{relCardinality=(O2O _ relColumns False)} -> colName col `elem` (fst <$> relColumns)
|
||||||
_ -> False
|
_ -> False
|
||||||
) relsSortedByIsView
|
) relsSortedByIsView
|
||||||
fCol = (headMay . (\r -> snd <$> relColumns (relCardinality r)) =<< rel)
|
-- Prefer real table relationships over the ones derived from views
|
||||||
fTbl = qiName . relForeignTable <$> rel
|
tableRels = filter (not . relFTableIsView) relsMatching
|
||||||
fTblCol = (,) <$> fTbl <*> fCol
|
relsSelected = if null tableRels then relsMatching else tableRels
|
||||||
in
|
in
|
||||||
(\(a, b) -> T.intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]) <$> fTblCol
|
fkNote <$> [ (qiName (relForeignTable r), relColumns (relCardinality r)) | r@Relationship{} <- relsSelected ]
|
||||||
|
fkNote :: (Text, [(FieldName, FieldName)]) -> Text
|
||||||
|
fkNote (tblName, cols) =
|
||||||
|
T.intercalate ""
|
||||||
|
[ "This is a Foreign Key to `", tblName, ".", refCols, "`.<fk table='", tblName, "' columns='", colPairs, "'/>" ]
|
||||||
|
where
|
||||||
|
refCols = case snd <$> cols of
|
||||||
|
[refCol] -> refCol
|
||||||
|
cols' -> "(" <> T.intercalate ", " cols' <> ")"
|
||||||
|
colPairs = T.intercalate "," [ localCol <> ":" <> foreignCol | (localCol, foreignCol) <- cols ]
|
||||||
pk :: Bool
|
pk :: Bool
|
||||||
pk = colName col `elem` tablePKCols tbl
|
pk = colName col `elem` tablePKCols tbl
|
||||||
uniqueNotes :: [Text]
|
uniqueNotes :: [Text]
|
||||||
@@ -177,7 +186,7 @@ makeProperty tbl rels col = (colName col, Inline s)
|
|||||||
, if pk then Just "This is a Primary Key.<pk/>" else Nothing
|
, if pk then Just "This is a Primary Key.<pk/>" else Nothing
|
||||||
]
|
]
|
||||||
<> uniqueNotes
|
<> uniqueNotes
|
||||||
<> catMaybes [fk]
|
<> fks
|
||||||
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)
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"parent_id": {
|
"parent_id": {
|
||||||
"description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' column='id'/>",
|
"description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' columns='parent_id:id'/>",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
@@ -200,7 +200,7 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"parent_id": {
|
"parent_id": {
|
||||||
"description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' column='id'/>",
|
"description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' columns='parent_id:id'/>",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
@@ -285,7 +285,7 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
|
|||||||
{
|
{
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Note:\nThis is a Unique column.<unique/>\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' columns='second_id_1:id'/>"
|
||||||
}
|
}
|
||||||
|]
|
|]
|
||||||
|
|
||||||
@@ -370,6 +370,45 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
|
|||||||
beingDescription `shouldBe` Just
|
beingDescription `shouldBe` Just
|
||||||
[aesonQQ|"<m2m table='part' junction='being_part' source='being' target='part'/>"|]
|
[aesonQQ|"<m2m table='part' junction='being_part' source='being' target='part'/>"|]
|
||||||
|
|
||||||
|
it "includes a composite foreign key marker with the full column mapping" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let parentFk = r ^? key "definitions" . key "comp_component_instance" . key "properties" . key "product_instance_id"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
parentFk `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "int32",
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Note:\nThis is a Primary Key.<pk/>\nThis is a Foreign Key to `comp_product_instance.(id, product_id)`.<fk table='comp_product_instance' columns='product_instance_id:id,product_id:product_id'/>"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "includes a single column foreign key marker with the unified format" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let productFk = r ^? key "definitions" . key "comp_product_instance" . key "properties" . key "product_id"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
productFk `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "int32",
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Note:\nThis is part of a composite unique constraint.<unique cols='id,product_id'/>\nThis is a Foreign Key to `comp_product.id`.<fk table='comp_product' columns='product_id:id'/>"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "includes composite m2m relationship markers in the table description" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let beingDescription = r ^? key "definitions" . key "comp_being" . key "description"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
beingDescription `shouldBe` Just
|
||||||
|
[aesonQQ|"<m2m table='comp_thing' junction='comp_being_thing' source='a,b' target='x,y'/>"|]
|
||||||
|
|
||||||
describe "Foreign table" $
|
describe "Foreign table" $
|
||||||
|
|
||||||
it "includes foreign table properties" $ do
|
it "includes foreign table properties" $ do
|
||||||
@@ -471,7 +510,7 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
|
|||||||
{
|
{
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' column='link'/>"
|
"description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' columns='link:link'/>"
|
||||||
}
|
}
|
||||||
|]
|
|]
|
||||||
|
|
||||||
@@ -488,7 +527,7 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
|
|||||||
{
|
{
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Note:\nThis is a Foreign Key to `clients.id`.<fk table='clients' column='id'/>"
|
"description": "Note:\nThis is a Foreign Key to `clients.id`.<fk table='clients' columns='client_id:id'/>"
|
||||||
}
|
}
|
||||||
|]
|
|]
|
||||||
|
|
||||||
|
|||||||
Vendored
+52
@@ -1246,6 +1246,58 @@ create table test.being_part (
|
|||||||
primary key(being, part)
|
primary key(being, part)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Tables for testing composite foreign keys in the OpenAPI output
|
||||||
|
create table test.comp_product (
|
||||||
|
id int primary key,
|
||||||
|
name text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table test.comp_component (
|
||||||
|
product_id int not null references test.comp_product(id),
|
||||||
|
component_id int not null references test.comp_product(id),
|
||||||
|
primary key(product_id, component_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table test.comp_product_instance (
|
||||||
|
id int primary key,
|
||||||
|
product_id int not null references test.comp_product(id),
|
||||||
|
unique(id, product_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table test.comp_component_instance (
|
||||||
|
product_instance_id int not null,
|
||||||
|
product_id int not null,
|
||||||
|
component_instance_id int not null,
|
||||||
|
component_id int not null,
|
||||||
|
primary key(product_instance_id, component_id),
|
||||||
|
constraint comp_component_instance_parent_fkey foreign key (product_instance_id, product_id) references test.comp_product_instance(id, product_id),
|
||||||
|
constraint comp_component_instance_child_fkey foreign key (component_instance_id, component_id) references test.comp_product_instance(id, product_id),
|
||||||
|
constraint comp_component_instance_component_fkey foreign key (product_id, component_id) references test.comp_component(product_id, component_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tables for testing composite m2m relationships in the OpenAPI output
|
||||||
|
create table test.comp_being (
|
||||||
|
a int not null,
|
||||||
|
b int not null,
|
||||||
|
primary key(a, b)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table test.comp_thing (
|
||||||
|
x int not null,
|
||||||
|
y int not null,
|
||||||
|
primary key(x, y)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table test.comp_being_thing (
|
||||||
|
a int not null,
|
||||||
|
b int not null,
|
||||||
|
x int not null,
|
||||||
|
y int not null,
|
||||||
|
primary key(a, b, x, y),
|
||||||
|
constraint comp_being_thing_being_fkey foreign key (a, b) references test.comp_being(a, b),
|
||||||
|
constraint comp_being_thing_thing_fkey foreign key (x, y) references test.comp_thing(x, y)
|
||||||
|
);
|
||||||
|
|
||||||
create function test.single_out_param(num int, OUT num_plus_one int) AS $$
|
create function test.single_out_param(num int, OUT num_plus_one int) AS $$
|
||||||
select num + 1;
|
select num + 1;
|
||||||
$$ language sql;
|
$$ language sql;
|
||||||
|
|||||||
Reference in New Issue
Block a user