From d2719420f46a537a75d76a4666f8bffec582d4f8 Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Sat, 16 Apr 2022 10:40:51 -0500 Subject: [PATCH] refactor: PKcols in table, ViewKeyDependency type * Get PKcols inside tables - done with SQL for tables and with an additional step in Haskell for views. This fixes an fk column being considered as a pk column on views and corrects the test added on https://github.com/PostgREST/postgrest/pull/1875/files/1d549768580310e18aac4ffa6dbd01c5b77934a7#r853674126 * classify view key dependencies in SQL * remove Column from Relationship * Merge cols/fcols in Relationship and ensure allM2ORels and allViewsKeyDependencies fk columns are ordered - done by attnum in SQL * Cardinality now contains relColumns instead of Relationship - this simplifies getJoinConditions. --- CHANGELOG.md | 1 + src/PostgREST/App.hs | 8 +- src/PostgREST/DbStructure.hs | 434 ++++++++++------------ src/PostgREST/DbStructure/Relationship.hs | 37 +- src/PostgREST/DbStructure/Table.hs | 1 + src/PostgREST/Error.hs | 16 +- src/PostgREST/OpenAPI.hs | 56 ++- src/PostgREST/Query/QueryBuilder.hs | 2 +- src/PostgREST/Request/DbRequestBuilder.hs | 54 +-- test/spec/Feature/OpenApi/RootSpec.hs | 2 +- test/spec/Feature/Query/InsertSpec.hs | 6 +- 11 files changed, 293 insertions(+), 324 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a83abeac7..6159173611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - #2147, Ignore `Content-Type` headers for `GET` requests when calling RPCs. Previously, `GET` without parameters, but with `Content-Type: text/plain` or `Content-Type: application/octet-stream` would fail with `404 Not Found`, even if a function without arguments was available. - #2155, Ignore `max-rows` on POST, PATCH, PUT and DELETE - @steve-chavez - #2239, Fix misleading disambiguation error where the content of the `relationship` key looks like valid syntax - @laurenceisla + - #2254, Fix inferring a foreign key column as a primary key column on views - @steve-chavez ### Changed diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index c00076507d..a7b3174edd 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -63,7 +63,7 @@ import PostgREST.Config (AppConfig (..), import PostgREST.Config.PgVersion (PgVersion (..)) import PostgREST.ContentType (ContentType (..)) import PostgREST.DbStructure (DbStructure (..), - findIfView, tablePKCols) + findIfView) import PostgREST.DbStructure.Identifiers (FieldName, QualifiedIdentifier (..), Schema) @@ -313,7 +313,7 @@ handleCreate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response handleCreate identifier@QualifiedIdentifier{..} context@RequestContext{..} = do let ApiRequest{..} = ctxApiRequest - pkCols = tablePKCols ctxDbStructure qiSchema qiName + pkCols = maybe mempty tablePKCols $ M.lookup identifier $ dbTables ctxDbStructure WriteQueryResult{..} <- writeQuery MutationCreate identifier True pkCols context @@ -433,7 +433,7 @@ handleInfo identifier RequestContext{..} = ++ ["DELETE" | tableDeletable table] ) hasPK = - not $ null $ tablePKCols ctxDbStructure (qiSchema identifier) (qiName identifier) + not $ null $ maybe mempty tablePKCols $ M.lookup identifier (dbTables ctxDbStructure) handleInvoke :: InvokeMethod -> ProcDescription -> RequestContext -> DbHandler Wai.Response handleInvoke invMethod proc context@RequestContext{..} = do @@ -537,7 +537,7 @@ writeQuery mutation identifier@QualifiedIdentifier{..} isInsert pkCols context@R mutateReq <- liftEither $ ReqBuilder.mutateRequest mutation qiSchema qiName ctxApiRequest - (tablePKCols ctxDbStructure qiSchema qiName) + (maybe mempty tablePKCols $ M.lookup identifier $ dbTables ctxDbStructure) readReq (_, queryTotal, fields, body, gucHeaders, gucStatus) <- diff --git a/src/PostgREST/DbStructure.hs b/src/PostgREST/DbStructure.hs index fadaf8dc40..657ab60b91 100644 --- a/src/PostgREST/DbStructure.hs +++ b/src/PostgREST/DbStructure.hs @@ -26,26 +26,24 @@ module PostgREST.DbStructure , findIfView , schemaDescription , tableCols - , tablePKCols ) where import qualified Data.Aeson as JSON import qualified Data.HashMap.Strict as M -import qualified Data.List as L import qualified Hasql.Decoders as HD import qualified Hasql.Encoders as HE import qualified Hasql.Statement as SQL import qualified Hasql.Transaction as SQL import Contravariant.Extras (contrazip2) -import Data.Set as S (fromList) import Data.Text (split) import Text.InterpolatedString.Perl6 (q) import PostgREST.Config.Database (pgVersionStatement) import PostgREST.Config.PgVersion (PgVersion, pgVersion100, pgVersion110) -import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..), +import PostgREST.DbStructure.Identifiers (FieldName, + QualifiedIdentifier (..), Schema, TableName) import PostgREST.DbStructure.Proc (PgType (..), ProcDescription (..), @@ -54,20 +52,17 @@ import PostgREST.DbStructure.Proc (PgType (..), ProcsMap, RetType (..)) import PostgREST.DbStructure.Relationship (Cardinality (..), Junction (..), - PrimaryKey (..), Relationship (..)) import PostgREST.DbStructure.Table (Column (..), Table (..), TablesMap) import Protolude -import Protolude.Unsafe (unsafeHead) data DbStructure = DbStructure { dbTables :: TablesMap , dbColumns :: [Column] , dbRelationships :: [Relationship] - , dbPrimaryKeys :: [PrimaryKey] , dbProcs :: ProcsMap } deriving (Generic, JSON.ToJSON) @@ -76,16 +71,22 @@ data DbStructure = DbStructure tableCols :: DbStructure -> Schema -> TableName -> [Column] tableCols dbs tSchema tName = filter (\Column{colTable=Table{tableSchema=s, tableName=t}} -> s==tSchema && t==tName) $ dbColumns dbs --- TODO Table could hold references to all its PrimaryKeys -tablePKCols :: DbStructure -> Schema -> TableName -> [Text] -tablePKCols dbs tSchema tName = pkName <$> filter (\pk -> tSchema == (tableSchema . pkTable) pk && tName == (tableName . pkTable) pk) (dbPrimaryKeys dbs) - findIfView :: QualifiedIdentifier -> TablesMap -> Bool findIfView identifier tbls = maybe False tableIsView $ M.lookup identifier tbls --- | The source table column a view column refers to -type SourceColumn = (Column, ViewColumn) -type ViewColumn = Column +-- | A view foreign key or primary key dependency detected on its source table +data ViewKeyDependency = ViewKeyDependency { + keyDepTable :: QualifiedIdentifier +, keyDepView :: QualifiedIdentifier +, keyDepCons :: Text +, keyDepType :: KeyDep +, keyDepCols :: [(FieldName, FieldName)] -- ^ First element is the table column, second is the view column +} deriving (Eq) +data KeyDep + = PKDep -- ^ PK dependency + | FKDep -- ^ FK dependency + | FKDepRef -- ^ FK reference dependency + deriving (Eq) -- | A SQL query that can be executed independently type SqlQuery = ByteString @@ -96,19 +97,17 @@ queryDbStructure schemas extraSearchPath prepared = do pgVer <- SQL.statement mempty pgVersionStatement tabs <- SQL.statement mempty $ allTables pgVer prepared cols <- SQL.statement schemas $ allColumns tabs prepared - srcCols <- SQL.statement (schemas, extraSearchPath) $ pfkSourceColumns cols prepared - m2oRels <- SQL.statement mempty $ allM2ORels pgVer cols prepared - keys <- SQL.statement mempty $ allPrimaryKeys tabs prepared + keyDeps <- SQL.statement (schemas, extraSearchPath) $ allViewsKeyDependencies prepared + m2oRels <- SQL.statement mempty $ allM2ORels pgVer prepared procs <- SQL.statement schemas $ allProcs pgVer prepared - let rels = addO2MRels . addM2MRels $ addViewM2ORels srcCols m2oRels - keys' = addViewPrimaryKeys srcCols keys + let rels = addO2MRels $ addM2MRels $ addViewM2ORels keyDeps m2oRels + tabsWViewsPks = addViewPrimaryKeys tabs keyDeps return $ removeInternal schemas $ DbStructure { - dbTables = tabs + dbTables = tabsWViewsPks , dbColumns = cols , dbRelationships = rels - , dbPrimaryKeys = keys' , dbProcs = procs } @@ -121,7 +120,6 @@ removeInternal schemas dbStruct = , dbRelationships = filter (\x -> qiSchema (relTable x) `elem` schemas && qiSchema (relForeignTable x) `elem` schemas && not (hasInternalJunction x)) $ dbRelationships dbStruct - , dbPrimaryKeys = filter (\x -> tableSchema (pkTable x) `elem` schemas) $ dbPrimaryKeys dbStruct , dbProcs = dbProcs dbStruct -- procs are only obtained from the exposed schemas, no need to filter them. } where @@ -140,6 +138,7 @@ decodeTables = <*> column HD.bool <*> column HD.bool <*> column HD.bool + <*> arrayColumn HD.text decodeColumns :: TablesMap -> HD.Result [Column] decodeColumns tables = @@ -157,40 +156,42 @@ decodeColumns tables = <*> nullableColumn HD.text <*> nullableColumn HD.text -decodeRels :: [Column] -> HD.Result [Relationship] -decodeRels cols = - mapMaybe (relFromRow cols) <$> HD.rowList relRow +decodeRels :: HD.Result [Relationship] +decodeRels = + map relFromRow <$> HD.rowList relRow where - relRow = (,,,,,,) - <$> column HD.text - <*> column HD.text - <*> column HD.text - <*> arrayColumn HD.text - <*> column HD.text + relRow = (,,,,,) + <$> column HD.text <*> column HD.text + <*> column HD.text <*> column HD.text <*> column HD.text - <*> arrayColumn HD.text - -decodePks :: TablesMap -> HD.Result [PrimaryKey] -decodePks tables = - mapMaybe (pkFromRow tables) <$> HD.rowList pkRow - where - pkRow = (,,) <$> column HD.text <*> column HD.text <*> column HD.text - -decodeSourceColumns :: [Column] -> HD.Result [SourceColumn] -decodeSourceColumns cols = - mapMaybe (sourceColumnFromRow cols) <$> HD.rowList srcColRow + <*> compositeArrayColumn + ((,) + <$> compositeField HD.text + <*> compositeField HD.text) + +relFromRow :: (Text, Text, Text, Text, Text, [(Text, Text)]) -> Relationship +relFromRow (rs, rt, frs, frt, cn, cs) = + Relationship (QualifiedIdentifier rs rt) (QualifiedIdentifier frs frt) (M2O cn cs) + +decodeViewKeyDeps :: HD.Result [ViewKeyDependency] +decodeViewKeyDeps = + map viewKeyDepFromRow <$> HD.rowList row where - srcColRow = (,,,,,) + row = (,,,,,,) <$> column HD.text <*> column HD.text <*> column HD.text <*> column HD.text <*> column HD.text <*> column HD.text + <*> compositeArrayColumn + ((,) + <$> compositeField HD.text + <*> compositeField HD.text) -sourceColumnFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe SourceColumn -sourceColumnFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2 +viewKeyDepFromRow :: (Text,Text,Text,Text,Text,Text,[(Text, Text)]) -> ViewKeyDependency +viewKeyDepFromRow (s1,t1,s2,v2,cons,consType,sCols) = ViewKeyDependency (QualifiedIdentifier s1 t1) (QualifiedIdentifier s2 v2) cons keyDep sCols where - col1 = findCol s1 t1 c1 - col2 = findCol s2 t2 c2 - findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols + keyDep | consType == "p" = PKDep + | consType == "f" = FKDep + | otherwise = FKDepRef -- f_ref, we build this type in the query decodeProcs :: HD.Result ProcsMap decodeProcs = @@ -337,91 +338,71 @@ accessibleTables pgVer = sql = tablesSqlQuery False pgVer {- -Adds Views M2O Relationships based on SourceColumns found, the logic is as follows: +Adds M2O relationships for views to tables, tables to views, and views to views. The example below is taken from the test fixtures, but the views names/colnames were modified. -Having a Relationship{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relCardinality=M2O} represented by: +--allM2ORels sample query result-- +private | personnages | private | actors | personnages_role_id_fkey | {"(role_id,id)"} -t1.c1------t2.c2 +--allViewsKeyDependencies sample query result-- +private | personnages | test | personnages_view | personnages_role_id_fkey | f | {"(role_id,roleId)"} +private | actors | test | actors_view | personnages_role_id_fkey | f_ref | {"(id,actorId)"} -When only having a t1_view.c1 source column, we need to add a View-Table M2O Relationship - - t1.c1----t2.c2 t1.c1----------t2.c2 - -> ________/ - / - t1_view.c1 t1_view.c1 - - -When only having a t2_view.c2 source column, we need to add a Table-View M2O Relationship - - t1.c1----t2.c2 t1.c1----------t2.c2 - -> \________ - \ - t2_view.c2 t2_view.c1 - -When having t1_view.c1 and a t2_view.c2 source columns, we need to add a View-View M2O Relationship in addition to the prior - - t1.c1----t2.c2 t1.c1----------t2.c2 - -> \________/ - / \ - t1_view.c1 t2_view.c2 t1_view.c1-------t2_view.c1 - -The logic for composite pks is similar just need to make sure all the Relationship columns have source columns. +--this function result-- +test | personnages_view | private | actors | personnages_role_id_fkey | f | {"(roleId,id)"} | viewTableM2O +private | personnages | test | actors_view | personnages_role_id_fkey | f_ref | {"(role_id,actorId)"} | tableViewM2O +test | personnages_view | test | actors_view | personnages_role_id_fkey | f,r_ref | {"(roleId,actorId)"} | viewViewM2O -} -addViewM2ORels :: [SourceColumn] -> [Relationship] -> [Relationship] -addViewM2ORels allSrcCols = concatMap (\rel@Relationship{..} -> rel : - let - srcColsGroupedByView :: [Column] -> [[SourceColumn]] - srcColsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $ - filter (\(c, _) -> c `elem` relCols) allSrcCols - relSrcCols = srcColsGroupedByView relColumns - relFSrcCols = srcColsGroupedByView relForeignColumns - getView :: [SourceColumn] -> QualifiedIdentifier - getView = (\t -> QualifiedIdentifier (tableSchema t) (tableName t)) . colTable . snd . unsafeHead - srcCols `allSrcColsOf` cols = S.fromList (fst <$> srcCols) == S.fromList cols - -- Relationship is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query. - -- So we need to change the order of the SourceColumns to match the relColumns - -- TODO: This could be avoided if the Relationship type is improved with a structure that maintains the association of relColumns and relFColumns - srcCols `sortAccordingTo` cols = sortOn (\(k, _) -> L.lookup k $ zip cols [0::Int ..]) srcCols - - viewTableM2O = - [ Relationship - (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns) - relForeignTable relForeignColumns relCardinality - | srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns ] - - tableViewM2O = - [ Relationship - relTable relColumns - (getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relForeignColumns) - relCardinality - | fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relForeignColumns ] - - viewViewM2O = - [ Relationship - (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns) - (getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relForeignColumns) - relCardinality - | srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns - , fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relForeignColumns ] - - in viewTableM2O ++ tableViewM2O ++ viewViewM2O) +addViewM2ORels :: [ViewKeyDependency] -> [Relationship] -> [Relationship] +addViewM2ORels keyDeps rels = + rels ++ concat (viewRels <$> rels) + where + viewRels Relationship{relTable,relForeignTable,relCardinality=M2O cons relColumns} = + let + viewTableM2Os = filter (\ViewKeyDependency{keyDepTable, keyDepCons, keyDepType} -> keyDepTable == relTable && keyDepCons == cons && keyDepType == FKDep) keyDeps + tableViewM2Os = filter (\ViewKeyDependency{keyDepTable, keyDepCons, keyDepType} -> keyDepTable == relForeignTable && keyDepCons == cons && keyDepType == FKDepRef) keyDeps + in + [ Relationship + (keyDepView vwTbl) + relForeignTable + (M2O cons $ zipWith (\(_, vCol) (_, fCol)-> (vCol, fCol)) (keyDepCols vwTbl) relColumns) + | vwTbl <- viewTableM2Os ] + ++ + [ Relationship + relTable + (keyDepView tblVw) + (M2O cons $ zipWith (\(tCol, _) (_, vCol) -> (tCol, vCol)) relColumns (keyDepCols tblVw)) + | tblVw <- tableViewM2Os ] + ++ + [ Relationship + (keyDepView vwTbl) + (keyDepView tblVw) + (M2O cons $ zipWith (\(_, vcol1) (_, vcol2) -> (vcol1, vcol2)) (keyDepCols vwTbl) (keyDepCols tblVw)) + | vwTbl <- viewTableM2Os + , tblVw <- tableViewM2Os ] + viewRels _ = [] + addO2MRels :: [Relationship] -> [Relationship] -addO2MRels rels = rels ++ [ Relationship ft fc t c (O2M cons) - | Relationship t c ft fc (M2O cons) <- rels ] +addO2MRels rels = rels ++ [ Relationship ft t (O2M cons (swap <$> cols)) + | Relationship t ft (M2O cons cols) <- rels ] addM2MRels :: [Relationship] -> [Relationship] -addM2MRels rels = rels ++ [ Relationship t c ft fc (M2M $ Junction jt1 cons1 jc1 cons2 jc2) - | Relationship jt1 jc1 t c (M2O cons1) <- rels - , Relationship jt2 jc2 ft fc (M2O cons2) <- rels +addM2MRels rels = rels ++ [ Relationship t ft + (M2M $ Junction jt1 cons1 cons2 (swap <$> cols) (swap <$> fcols)) + | Relationship jt1 t (M2O cons1 cols) <- rels + , Relationship jt2 ft (M2O cons2 fcols) <- rels , jt1 == jt2 , cons1 /= cons2] -addViewPrimaryKeys :: [SourceColumn] -> [PrimaryKey] -> [PrimaryKey] -addViewPrimaryKeys srcCols = concatMap (\pk -> - let viewPks = (\(_, viewCol) -> PrimaryKey{pkTable=colTable viewCol, pkName=colName viewCol}) <$> - filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) srcCols in - pk : viewPks) +addViewPrimaryKeys :: TablesMap -> [ViewKeyDependency] -> TablesMap +addViewPrimaryKeys tabs keyDeps = + (\tbl@Table{tableSchema, tableName, tableIsView}-> if tableIsView + then tbl{tablePKCols=findViewPKCols tableSchema tableName} + else tbl) <$> tabs + where + findViewPKCols sch vw = + maybe [] (\(ViewKeyDependency _ _ _ _ pkCols) -> snd <$> pkCols) $ + find (\(ViewKeyDependency _ viewQi _ dep _) -> dep == PKDep && viewQi == QualifiedIdentifier sch vw) keyDeps allTables :: PgVersion -> Bool -> SQL.Statement () TablesMap allTables pgVer = @@ -429,8 +410,84 @@ allTables pgVer = where sql = tablesSqlQuery True pgVer +-- | Gets tables with their PK cols tablesSqlQuery :: Bool -> PgVersion -> SqlQuery -tablesSqlQuery getAll pgVer = [q| +tablesSqlQuery getAll pgVer = + -- the tbl_constraints/key_col_usage CTEs are based on the standard "information_schema.table_constraints"/"information_schema.key_column_usage" views, + -- we cannot use those directly as they include the following privilege filter: + -- (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text)); + [q| + WITH + tbl_constraints AS ( + SELECT + c.conname::name AS constraint_name, + nr.nspname::name AS table_schema, + r.relname::name AS table_name + FROM pg_namespace nc + JOIN pg_constraint c ON nc.oid = c.connamespace + JOIN pg_class r ON c.conrelid = r.oid + JOIN pg_namespace nr ON nr.oid = r.relnamespace + WHERE + r.relkind IN ('r', 'p') + AND NOT pg_is_other_temp_schema(nr.oid) + AND c.contype = 'p' + ), + key_col_usage AS ( + SELECT + ss.conname::name AS constraint_name, + ss.nr_nspname::name AS table_schema, + ss.relname::name AS table_name, + a.attname::name AS column_name, + (ss.x).n::integer AS ordinal_position, + CASE + WHEN ss.contype = 'f' THEN information_schema._pg_index_position(ss.conindid, ss.confkey[(ss.x).n]) + ELSE NULL::integer + END::integer AS position_in_unique_constraint + FROM pg_attribute a + JOIN ( + SELECT r.oid AS roid, + r.relname, + r.relowner, + nc.nspname AS nc_nspname, + nr.nspname AS nr_nspname, + c.oid AS coid, + c.conname, + c.contype, + c.conindid, + c.confkey, + information_schema._pg_expandarray(c.conkey) AS x + FROM pg_namespace nr + JOIN pg_class r + ON nr.oid = r.relnamespace + JOIN pg_constraint c + ON r.oid = c.conrelid + JOIN pg_namespace nc + ON c.connamespace = nc.oid + WHERE + c.contype in ('p', 'u') + AND r.relkind IN ('r', 'p') + AND NOT pg_is_other_temp_schema(nr.oid) + ) ss ON a.attrelid = ss.roid AND a.attnum = (ss.x).x + WHERE + NOT a.attisdropped + ), + tbl_pk_cols AS ( + SELECT + key_col_usage.table_schema, + key_col_usage.table_name, + array_agg(key_col_usage.column_name) as pk_cols + FROM + tbl_constraints + JOIN + key_col_usage + ON + key_col_usage.table_name = tbl_constraints.table_name AND + key_col_usage.table_schema = tbl_constraints.table_schema AND + key_col_usage.constraint_name = tbl_constraints.constraint_name + WHERE + key_col_usage.table_schema NOT IN ('pg_catalog', 'information_schema') + GROUP BY key_col_usage.table_schema, key_col_usage.table_name + ) SELECT n.nspname AS table_schema, c.relname AS table_name, @@ -461,10 +518,12 @@ tablesSqlQuery getAll pgVer = [q| -- CMD_DELETE AND (pg_relation_is_updatable(c.oid::regclass, TRUE) & 16) = 16 ) - ) AS deletable + ) AS deletable, + coalesce(tpks.pk_cols, '{}') as pk_cols FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace - LEFT JOIN pg_description as d on d.objoid = c.oid and d.objsubid = 0 + LEFT JOIN pg_description d on d.objoid = c.oid and d.objsubid = 0 + LEFT JOIN tbl_pk_cols tpks ON n.nspname = tpks.table_schema AND c.relname = tpks.table_name WHERE c.relkind IN ('v','r','m','f','p') AND n.nspname NOT IN ('pg_catalog', 'information_schema') |] <> relIsPartition <> @@ -613,23 +672,20 @@ columnFromRow tabs (s, t, n, desc, nul, typ, l, d, e) = buildColumn <$> table parseEnum :: Maybe Text -> [Text] parseEnum = maybe [] (split (==',')) -allM2ORels :: PgVersion -> [Column] -> Bool -> SQL.Statement () [Relationship] -allM2ORels pgVer allCols = - SQL.Statement sql HE.noParams (decodeRels allCols) +allM2ORels :: PgVersion -> Bool -> SQL.Statement () [Relationship] +allM2ORels pgVer = + SQL.Statement sql HE.noParams decodeRels where sql = [q| SELECT ns1.nspname AS table_schema, tab.relname AS table_name, - conname AS constraint_name, - column_info.cols AS columns, ns2.nspname AS foreign_table_schema, other.relname AS foreign_table_name, - column_info.refs AS foreign_columns + conname AS constraint_name, + column_info.cols AS columns FROM pg_constraint, LATERAL ( - SELECT array_agg(cols.attname) AS cols, - array_agg(cols.attnum) AS nums, - array_agg(refs.attname) AS refs + SELECT array_agg(row(cols.attname, refs.attname) order by cols.attnum) AS cols FROM ( SELECT unnest(conkey) AS col, unnest(confkey) AS ref) k, LATERAL (SELECT * FROM pg_attribute WHERE attrelid = conrelid AND attnum = col) AS cols, LATERAL (SELECT * FROM pg_attribute WHERE attrelid = confrelid AND attnum = ref) AS refs) AS column_info, @@ -641,100 +697,12 @@ allM2ORels pgVer allCols = (if pgVer >= pgVersion110 then " and conparentid = 0 " else mempty) <> - "ORDER BY (conrelid, column_info.nums)" - -relFromRow :: [Column] -> (Text, Text, Text, [Text], Text, Text, [Text]) -> Maybe Relationship -relFromRow allCols (rs, rt, cn, rcs, frs, frt, frcs) = - Relationship (QualifiedIdentifier rs rt) <$> cols <*> pure (QualifiedIdentifier frs frt) <*> colsF <*> pure (M2O cn) - where - findCol s t c = find (\col -> tableSchema (colTable col) == s && tableName (colTable col) == t && colName col == c) allCols - cols = mapM (findCol rs rt) rcs - colsF = mapM (findCol frs frt) frcs - -allPrimaryKeys :: TablesMap -> Bool -> SQL.Statement () [PrimaryKey] -allPrimaryKeys tabs = - SQL.Statement sql HE.noParams (decodePks tabs) - where - -- these CTEs are based on the standard "information_schema.table_constraints" and "information_schema.key_column_usage" views, - -- we cannot use those directly as they include the following privilege filter: - -- (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text)); - sql = [q| - WITH tbl_constraints AS ( - SELECT - c.conname::name AS constraint_name, - nr.nspname::name AS table_schema, - r.relname::name AS table_name - FROM pg_namespace nc - JOIN pg_constraint c ON nc.oid = c.connamespace - JOIN pg_class r ON c.conrelid = r.oid - JOIN pg_namespace nr ON nr.oid = r.relnamespace - WHERE - r.relkind IN ('r', 'p') - AND NOT pg_is_other_temp_schema(nr.oid) - AND c.contype = 'p' - ), - key_col_usage AS ( - SELECT - ss.conname::name AS constraint_name, - ss.nr_nspname::name AS table_schema, - ss.relname::name AS table_name, - a.attname::name AS column_name, - (ss.x).n::integer AS ordinal_position, - CASE - WHEN ss.contype = 'f' THEN information_schema._pg_index_position(ss.conindid, ss.confkey[(ss.x).n]) - ELSE NULL::integer - END::integer AS position_in_unique_constraint - FROM pg_attribute a - JOIN ( - SELECT r.oid AS roid, - r.relname, - r.relowner, - nc.nspname AS nc_nspname, - nr.nspname AS nr_nspname, - c.oid AS coid, - c.conname, - c.contype, - c.conindid, - c.confkey, - information_schema._pg_expandarray(c.conkey) AS x - FROM pg_namespace nr - JOIN pg_class r - ON nr.oid = r.relnamespace - JOIN pg_constraint c - ON r.oid = c.conrelid - JOIN pg_namespace nc - ON c.connamespace = nc.oid - WHERE - c.contype in ('p', 'u') - AND r.relkind IN ('r', 'p') - AND NOT pg_is_other_temp_schema(nr.oid) - ) ss ON a.attrelid = ss.roid AND a.attnum = (ss.x).x - WHERE - NOT a.attisdropped - ) - SELECT - key_col_usage.table_schema, - key_col_usage.table_name, - key_col_usage.column_name - FROM - tbl_constraints - JOIN - key_col_usage - ON - key_col_usage.table_name = tbl_constraints.table_name AND - key_col_usage.table_schema = tbl_constraints.table_schema AND - key_col_usage.constraint_name = tbl_constraints.constraint_name - WHERE - key_col_usage.table_schema NOT IN ('pg_catalog', 'information_schema') |] - -pkFromRow :: TablesMap -> (Schema, Text, Text) -> Maybe PrimaryKey -pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n - where table = M.lookup (QualifiedIdentifier s t) tabs + "ORDER BY conrelid, conname" --- returns all the primary and foreign key columns which are referenced in views -pfkSourceColumns :: [Column] -> Bool -> SQL.Statement ([Schema], [Schema]) [SourceColumn] -pfkSourceColumns cols = - SQL.Statement sql (contrazip2 (arrayParam HE.text) (arrayParam HE.text)) (decodeSourceColumns cols) +-- | Returns all the views' primary keys and foreign keys dependencies +allViewsKeyDependencies :: Bool -> SQL.Statement ([Schema], [Schema]) [ViewKeyDependency] +allViewsKeyDependencies = + SQL.Statement sql (contrazip2 (arrayParam HE.text) (arrayParam HE.text)) decodeViewKeyDeps -- query explanation at: -- * rationale: https://gist.github.com/wolfgangwalther/5425d64e7b0d20aad71f6f68474d9f19 -- * json transformation: https://gist.github.com/wolfgangwalther/3a8939da680c24ad767e93ad2c183089 @@ -744,6 +712,8 @@ pfkSourceColumns cols = pks_fks as ( -- pk + fk referencing col select + contype, + conname, conrelid as resorigtbl, unnest(conkey) as resorigcol from pg_constraint @@ -751,6 +721,8 @@ pfkSourceColumns cols = union -- fk referenced col select + concat(contype, '_ref') as contype, + conname, confrelid, unnest(confkey) from pg_constraint @@ -879,17 +851,19 @@ pfkSourceColumns cols = select sch.nspname as table_schema, tbl.relname as table_name, - col.attname as table_column_name, rec.view_schema, rec.view_name, - vcol.attname as view_column_name + pks_fks.conname as constraint_name, + pks_fks.contype as constraint_type, + array_agg(row(col.attname, vcol.attname) order by col.attnum) as column_dependencies from recursion rec join pg_class tbl on tbl.oid = rec.resorigtbl join pg_attribute col on col.attrelid = tbl.oid and col.attnum = rec.resorigcol join pg_attribute vcol on vcol.attrelid = rec.view_id and vcol.attnum = rec.view_column join pg_namespace sch on sch.oid = tbl.relnamespace join pks_fks using (resorigtbl, resorigcol) - order by view_schema, view_name, view_column_name; |] + group by sch.nspname, tbl.relname, rec.view_schema, rec.view_name, pks_fks.conname, pks_fks.contype + |] param :: HE.Value a -> HE.Params a param = HE.param . HE.nonNullable diff --git a/src/PostgREST/DbStructure/Relationship.hs b/src/PostgREST/DbStructure/Relationship.hs index 000f018bb2..4f0fcc09af 100644 --- a/src/PostgREST/DbStructure/Relationship.hs +++ b/src/PostgREST/DbStructure/Relationship.hs @@ -3,7 +3,6 @@ module PostgREST.DbStructure.Relationship ( Cardinality(..) - , PrimaryKey(..) , Relationship(..) , Junction(..) , isSelfReference @@ -11,24 +10,17 @@ module PostgREST.DbStructure.Relationship import qualified Data.Aeson as JSON -import PostgREST.DbStructure.Identifiers (QualifiedIdentifier) -import PostgREST.DbStructure.Table (Column (..), Table) +import PostgREST.DbStructure.Identifiers (FieldName, + QualifiedIdentifier) import Protolude -- | Relationship between two tables. --- --- The order of the relColumns and relForeignColumns should be maintained to get the --- join conditions right. --- --- TODO merge relColumns and relForeignColumns to a tuple or Data.Bimap data Relationship = Relationship - { relTable :: QualifiedIdentifier - , relColumns :: [Column] - , relForeignTable :: QualifiedIdentifier - , relForeignColumns :: [Column] - , relCardinality :: Cardinality + { relTable :: QualifiedIdentifier + , relForeignTable :: QualifiedIdentifier + , relCardinality :: Cardinality } deriving (Eq, Generic, JSON.ToJSON) @@ -36,9 +28,12 @@ data Relationship = Relationship -- | https://en.wikipedia.org/wiki/Cardinality_(data_modeling) -- TODO: missing one-to-one data Cardinality - = O2M FKConstraint -- ^ one-to-many cardinality - | M2O FKConstraint -- ^ many-to-one cardinality - | M2M Junction -- ^ many-to-many cardinality + = O2M {relCons :: FKConstraint, relColumns :: [(FieldName, FieldName)]} + -- ^ one-to-many + | M2O {relCons :: FKConstraint, relColumns :: [(FieldName, FieldName)]} + -- ^ many-to-one + | M2M Junction + -- ^ many-to-many deriving (Eq, Generic, JSON.ToJSON) type FKConstraint = Text @@ -47,17 +42,11 @@ type FKConstraint = Text data Junction = Junction { junTable :: QualifiedIdentifier , junConstraint1 :: FKConstraint - , junColumns1 :: [Column] , junConstraint2 :: FKConstraint - , junColumns2 :: [Column] + , junColumns1 :: [(FieldName, FieldName)] + , junColumns2 :: [(FieldName, FieldName)] } deriving (Eq, Generic, JSON.ToJSON) isSelfReference :: Relationship -> Bool isSelfReference r = relTable r == relForeignTable r - -data PrimaryKey = PrimaryKey - { pkTable :: Table - , pkName :: Text - } - deriving (Generic, JSON.ToJSON) diff --git a/src/PostgREST/DbStructure/Table.hs b/src/PostgREST/DbStructure/Table.hs index 9f48a1f910..0fdc7c7deb 100644 --- a/src/PostgREST/DbStructure/Table.hs +++ b/src/PostgREST/DbStructure/Table.hs @@ -27,6 +27,7 @@ data Table = Table , tableInsertable :: Bool , tableUpdatable :: Bool , tableDeletable :: Bool + , tablePKCols :: [FieldName] } deriving (Show, Ord, Generic, JSON.ToJSON) diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index 2847a1e903..951a71ecd7 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -40,8 +40,6 @@ import PostgREST.DbStructure.Proc (ProcDescription (..), import PostgREST.DbStructure.Relationship (Cardinality (..), Junction (..), Relationship (..)) -import PostgREST.DbStructure.Table (Column (..)) - import Protolude @@ -158,15 +156,15 @@ compressedRel Relationship{..} = : case relCardinality of M2M Junction{..} -> [ "cardinality" .= ("many-to-many" :: Text) - , "relationship" .= (qiName junTable <> " using " <> junConstraint1 <> fmtEls (colName <$> junColumns1) <> " and " <> junConstraint2 <> fmtEls (colName <$> junColumns2)) + , "relationship" .= (qiName junTable <> " using " <> junConstraint1 <> fmtEls (snd <$> junColumns1) <> " and " <> junConstraint2 <> fmtEls (snd <$> junColumns2)) ] - M2O cons -> [ + M2O cons relColumns -> [ "cardinality" .= ("many-to-one" :: Text) - , "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (colName <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (colName <$> relForeignColumns)) + , "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (fst <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (snd <$> relColumns)) ] - O2M cons -> [ + O2M cons relColumns -> [ "cardinality" .= ("one-to-many" :: Text) - , "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (colName <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (colName <$> relForeignColumns)) + , "relationship" .= (cons <> " using " <> qiName relTable <> fmtEls (fst <$> relColumns) <> " and " <> qiName relForeignTable <> fmtEls (snd <$> relColumns)) ] relHint :: [Relationship] -> Text @@ -176,8 +174,8 @@ relHint rels = T.intercalate ", " (hintList <$> rels) let buildHint rel = "'" <> qiName relForeignTable <> "!" <> rel <> "'" in case relCardinality of M2M Junction{..} -> buildHint (qiName junTable) - M2O cons -> buildHint cons - O2M cons -> buildHint cons + M2O cons _ -> buildHint cons + O2M cons _ -> buildHint cons data PgError = PgError Authenticated SQL.UsageError type Authenticated = Bool diff --git a/src/PostgREST/OpenAPI.hs b/src/PostgREST/OpenAPI.hs index 7cd174819a..76aa3a6d6b 100644 --- a/src/PostgREST/OpenAPI.hs +++ b/src/PostgREST/OpenAPI.hs @@ -3,7 +3,6 @@ Module : PostgREST.OpenAPI Description : Generates the OpenAPI output -} {-# LANGUAGE LambdaCase #-} -{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE RecordWildCards #-} module PostgREST.OpenAPI (encode) where @@ -28,12 +27,11 @@ import Data.Swagger import PostgREST.Config (AppConfig (..), Proxy (..), isMalformedProxyUri, toURI) import PostgREST.DbStructure (DbStructure (..), - tableCols, tablePKCols) + tableCols) import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..)) import PostgREST.DbStructure.Proc (ProcDescription (..), ProcParam (..)) import PostgREST.DbStructure.Relationship (Cardinality (..), - PrimaryKey (..), Relationship (..)) import PostgREST.DbStructure.Table (Column (..), Table (..), TablesMap) @@ -52,7 +50,6 @@ encode conf dbStructure tables procs schemaDescription = (openApiTableInfo dbStructure <$> (snd <$> M.toList tables)) (proxyUri conf) schemaDescription - (dbPrimaryKeys dbStructure) makeMimeList :: [ContentType] -> MimeList makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs @@ -83,34 +80,34 @@ parseDefault colType colDefault = where wrapInQuotations text = "\"" <> text <> "\"" -makeTableDef :: [Relationship] -> [PrimaryKey] -> (Table, [Column], [Text]) -> (Text, Schema) -makeTableDef rels pks (t, cs, _) = +makeTableDef :: [Relationship] -> (Table, [Column]) -> (Text, Schema) +makeTableDef rels (t, cs) = let tn = tableName t in (tn, (mempty :: Schema) & description .~ tableDescription t & type_ ?~ SwaggerObject - & properties .~ fromList (fmap (makeProperty rels pks) cs) + & properties .~ fromList (fmap (makeProperty rels) cs) & required .~ fmap colName (filter (not . colNullable) cs)) -makeProperty :: [Relationship] -> [PrimaryKey] -> Column -> (Text, Referenced Schema) -makeProperty rels pks c = (colName c, Inline s) +makeProperty :: [Relationship] -> Column -> (Text, Referenced Schema) +makeProperty rels col = (colName col, Inline s) where - e = if null $ colEnum c then Nothing else JSON.decode $ JSON.encode $ colEnum c + e = if null $ colEnum col then Nothing else JSON.decode $ JSON.encode $ colEnum col fk :: Maybe Text fk = let -- Finds the relationship that has a single column foreign key rel = find (\case - Relationship{relColumns, relCardinality=M2O _} -> [c] == relColumns - _ -> False + Relationship{relCardinality=(M2O _ relColumns)} -> [colName col] == (fst <$> relColumns) + _ -> False ) rels - fCol = colName <$> (headMay . relForeignColumns =<< rel) + fCol = (headMay . (\r -> snd <$> relColumns (relCardinality r)) =<< rel) fTbl = qiName . relForeignTable <$> rel fTblCol = (,) <$> fTbl <*> fCol in (\(a, b) -> T.intercalate "" ["This is a Foreign Key to `", a, ".", b, "`."]) <$> fTblCol pk :: Bool - pk = any (\p -> pkTable p == colTable c && pkName p == colName c) pks + pk = colName col `elem` tablePKCols (colTable col) n = catMaybes [ Just "Note:" , if pk then Just "This is a Primary Key." else Nothing @@ -118,17 +115,17 @@ makeProperty rels pks c = (colName c, Inline s) ] d = if length n > 1 then - Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription c) (T.intercalate "\n" n) + Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n) else - colDescription c + colDescription col s = (mempty :: Schema) - & default_ .~ (JSON.decode . toUtf8Lazy . parseDefault (colType c) =<< colDefault c) + & default_ .~ (JSON.decode . toUtf8Lazy . parseDefault (colType col) =<< colDefault col) & description .~ d & enum_ .~ e - & format ?~ colType c - & maxLength .~ (fromIntegral <$> colMaxLen c) - & type_ .~ toSwaggerType (colType c) + & format ?~ colType col + & maxLength .~ (fromIntegral <$> colMaxLen col) + & type_ .~ toSwaggerType (colType col) makeProcSchema :: ProcDescription -> Schema makeProcSchema pd = @@ -165,7 +162,7 @@ makeProcParam pd = , Ref $ Reference "preferParams" ] -makeParamDefs :: [(Table, [Column], [Text])] -> [(Text, Param)] +makeParamDefs :: [(Table, [Column])] -> [(Text, Param)] makeParamDefs ti = [ ("preferParams", makePreferParam ["params=single-object"]) , ("preferReturn", makePreferParam ["return=representation", "return=minimal", "return=none"]) @@ -222,7 +219,7 @@ makeParamDefs ti = & type_ ?~ SwaggerString)) ] <> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) cs - | (t, cs, _) <- ti + | (t, cs) <- ti ] makeObjectBody :: Text -> (Text, Param) @@ -247,8 +244,8 @@ makeRowFilter tn c = makeRowFilters :: Text -> [Column] -> [(Text, Param)] makeRowFilters tn = fmap (makeRowFilter tn) -makePathItem :: (Table, [Column], [Text]) -> (FilePath, PathItem) -makePathItem (t, cs, _) = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t || tableDeletable t) +makePathItem :: (Table, [Column]) -> (FilePath, PathItem) +makePathItem (t, cs) = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t || tableDeletable t) where -- Use first line of table description as summary; rest as description (if present) -- We strip leading newlines from description so that users can include a blank line between summary and description @@ -312,7 +309,7 @@ makeRootPathItem = ("/", p) pr = (mempty :: PathItem) & get ?~ getOp p = pr -makePathItems :: [ProcDescription] -> [(Table, [Column], [Text])] -> InsOrdHashMap FilePath PathItem +makePathItems :: [ProcDescription] -> [(Table, [Column])] -> InsOrdHashMap FilePath PathItem makePathItems pds ti = fromList $ makeRootPathItem : fmap makePathItem ti ++ fmap makeProcPathItem pds @@ -324,8 +321,8 @@ escapeHostName "*6" = "0.0.0.0" escapeHostName "!6" = "0.0.0.0" escapeHostName h = h -postgrestSpec :: [Relationship] -> [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Maybe Text -> [PrimaryKey] -> Swagger -postgrestSpec rels pds ti (s, h, p, b) sd pks = (mempty :: Swagger) +postgrestSpec :: [Relationship] -> [ProcDescription] -> [(Table, [Column])] -> (Text, Text, Integer, Text) -> Maybe Text -> Swagger +postgrestSpec rels pds ti (s, h, p, b) sd = (mempty :: Swagger) & basePath ?~ T.unpack b & schemes ?~ [s'] & info .~ ((mempty :: Info) @@ -336,7 +333,7 @@ postgrestSpec rels pds ti (s, h, p, b) sd pks = (mempty :: Swagger) & description ?~ "PostgREST Documentation" & url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/api.html")) & host .~ h' - & definitions .~ fromList (makeTableDef rels pks <$> ti) + & definitions .~ fromList (makeTableDef rels <$> ti) & parameters .~ fromList (makeParamDefs ti) & paths .~ makePathItems pds ti & produces .~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV] @@ -383,9 +380,8 @@ proxyUri AppConfig{..} = Nothing -> ("http", configServerHost, toInteger configServerPort, "/") -openApiTableInfo :: DbStructure -> Table -> (Table, [Column], [Text]) +openApiTableInfo :: DbStructure -> Table -> (Table, [Column]) openApiTableInfo dbStructure table = ( table , tableCols dbStructure (tableSchema table) (tableName table) - , tablePKCols dbStructure (tableSchema table) (tableName table) ) diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index cedd68b095..3353fb4f2e 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -53,7 +53,7 @@ getJoinsSelects :: ReadRequest -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippe getJoinsSelects rr@(Node (_, (name, Just Relationship{relCardinality=card,relTable=QualifiedIdentifier{qiName=table}}, alias, _, joinType, _)) _) (joins,selects) = let subquery = readRequestToQuery rr in case card of - M2O _ -> + M2O _ _ -> let aliasOrName = fromMaybe name alias localTableName = pgFmtIdent $ table <> "_" <> aliasOrName sel = SQL.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName) diff --git a/src/PostgREST/Request/DbRequestBuilder.hs b/src/PostgREST/Request/DbRequestBuilder.hs index 3b5107d13c..a33da37ef2 100644 --- a/src/PostgREST/Request/DbRequestBuilder.hs +++ b/src/PostgREST/Request/DbRequestBuilder.hs @@ -37,7 +37,6 @@ import PostgREST.DbStructure.Proc (ProcDescription (..), import PostgREST.DbStructure.Relationship (Cardinality (..), Junction (..), Relationship (..)) -import PostgREST.DbStructure.Table (Column (..)) import PostgREST.Error (Error (..)) import PostgREST.Query.SqlFragment (sourceCTEName) import PostgREST.RangeQuery (NonnegRange, allRange, @@ -163,16 +162,23 @@ findRel schema allRels origin target hint = -- cardinalities(m2o/o2m) We output the O2M rel, the M2O rel can be -- obtained by using the origin column as an embed hint. rs@[rel0, rel1] -> case (relCardinality rel0, relCardinality rel1, relTable rel0 == relTable rel1 && relForeignTable rel0 == relForeignTable rel1) of - (O2M cons1, M2O cons2, True) -> if cons1 == cons2 then Right rel0 else Left $ AmbiguousRelBetween origin target rs - (M2O cons1, O2M cons2, True) -> if cons1 == cons2 then Right rel1 else Left $ AmbiguousRelBetween origin target rs + (O2M cons1 _, M2O cons2 _, True) -> if cons1 == cons2 then Right rel0 else Left $ AmbiguousRelBetween origin target rs + (M2O cons1 _, O2M cons2 _, True) -> if cons1 == cons2 then Right rel1 else Left $ AmbiguousRelBetween origin target rs _ -> Left $ AmbiguousRelBetween origin target rs rs -> Left $ AmbiguousRelBetween origin target rs where - matchFKSingleCol hint_ cols = length cols == 1 && hint_ == (colName <$> head cols) + matchFKSingleCol hint_ card = case card of + O2M _ cols -> length cols == 1 && hint_ == head (fst <$> cols) + M2O _ cols -> length cols == 1 && hint_ == head (fst <$> cols) + _ -> False + matchFKRefSingleCol hint_ card = case card of + O2M _ cols -> length cols == 1 && hint_ == head (snd <$> cols) + M2O _ cols -> length cols == 1 && hint_ == head (snd <$> cols) + _ -> False matchConstraint tar card = case card of - O2M cons -> tar == Just cons - M2O cons -> tar == Just cons - _ -> False + O2M cons _ -> tar == Just cons + M2O cons _ -> tar == Just cons + _ -> False matchJunction hint_ card = case card of M2M Junction{junTable} -> hint_ == Just (qiName junTable) _ -> False @@ -192,8 +198,8 @@ findRel schema allRels origin target hint = ) || -- /projects?select=client_id(*) ( - origin == qiName relTable && -- projects - matchFKSingleCol (Just target) relColumns -- client_id + origin == qiName relTable && -- projects + matchFKSingleCol (Just target) relCardinality -- client_id ) ) && ( isNothing hint || -- hint is optional @@ -202,8 +208,8 @@ findRel schema allRels origin target hint = matchConstraint hint relCardinality || -- projects_client_id_fkey -- /projects?select=clients!client_id(*) or /projects?select=clients!id(*) - matchFKSingleCol hint relColumns || -- client_id - matchFKSingleCol hint relForeignColumns || -- id + matchFKSingleCol hint relCardinality || -- client_id + matchFKRefSingleCol hint relCardinality || -- id -- /users?select=tasks!users_tasks(*) many-to-many between users and tasks matchJunction hint relCardinality -- users_tasks @@ -234,19 +240,21 @@ addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_ -- previousAlias and newAlias are used in the case of self joins getJoinConditions :: Maybe Alias -> Maybe Alias -> Relationship -> [JoinCondition] -getJoinConditions previousAlias newAlias (Relationship QualifiedIdentifier{qiSchema=tSchema, qiName=tN} cols QualifiedIdentifier{qiName=ftN} fCols card) = +getJoinConditions previousAlias newAlias (Relationship QualifiedIdentifier{qiSchema=tSchema, qiName=tN} QualifiedIdentifier{qiName=ftN} card) = case card of - M2M (Junction QualifiedIdentifier{qiName=jtn} _ jc1 _ jc2) -> - zipWith (toJoinCondition tN jtn) cols jc1 ++ zipWith (toJoinCondition ftN jtn) fCols jc2 - _ -> - zipWith (toJoinCondition tN ftN) cols fCols + M2M (Junction QualifiedIdentifier{qiName=jtn} _ _ jcols1 jcols2) -> + (toJoinCondition tN jtn <$> jcols1) ++ (toJoinCondition ftN jtn <$> jcols2) + O2M _ cols -> + toJoinCondition tN ftN <$> cols + M2O _ cols -> + toJoinCondition tN ftN <$> cols where - toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition - toJoinCondition tb ftb c fc = + toJoinCondition :: Text -> Text -> (FieldName, FieldName) -> JoinCondition + toJoinCondition tb ftb (c, fc) = let qi1 = removeSourceCTESchema tSchema tb qi2 = removeSourceCTESchema tSchema ftb in - JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, colName c) - (maybe qi2 (QualifiedIdentifier mempty) newAlias, colName fc) + JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, c) + (maybe qi2 (QualifiedIdentifier mempty) newAlias, fc) -- On mutation and calling proc cases we wrap the target table in a WITH -- {sourceCTEName} if this happens remove the schema `FROM @@ -380,7 +388,9 @@ returningCols rr@(Node _ forest) pkCols -- projects. So this adds the foreign key columns to ensure the embedding -- succeeds, result would be `RETURNING name, client_id`. fkCols = concat $ mapMaybe (\case - Node (_, (_, Just Relationship{relColumns=cols}, _, _, _, _)) _ -> Just cols + Node (_, (_, Just Relationship{relCardinality=O2M _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols + Node (_, (_, Just Relationship{relCardinality=M2O _ cols}, _, _, _, _)) _ -> Just $ fst <$> cols + Node (_, (_, Just Relationship{relCardinality=M2M Junction{junColumns1, junColumns2}}, _, _, _, _)) _ -> Just $ (fst <$> junColumns1) ++ (fst <$> junColumns2) _ -> Nothing ) forest -- However if the "client_id" is present, e.g. mutateRequest to @@ -390,7 +400,7 @@ returningCols rr@(Node _ forest) pkCols -- deduplicate with Set: We are adding the primary key columns as well to -- make sure, that a proper location header can always be built for -- INSERT/POST - returnings = S.toList . S.fromList $ fldNames ++ (colName <$> fkCols) ++ pkCols + returnings = S.toList . S.fromList $ fldNames ++ fkCols ++ pkCols -- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree -- they are later concatenated with AND in the QueryBuilder diff --git a/test/spec/Feature/OpenApi/RootSpec.hs b/test/spec/Feature/OpenApi/RootSpec.hs index 4f460cbd39..e894f54d07 100644 --- a/test/spec/Feature/OpenApi/RootSpec.hs +++ b/test/spec/Feature/OpenApi/RootSpec.hs @@ -27,6 +27,6 @@ spec = request methodGet "/" [("Accept", "application/json")] "" `shouldRespondWith` [json| { - "qiSchema":"test","qiName":"orders_view" + "qiSchema":"test","qiName":"has_fk" } |] { matchHeaders = [matchContentTypeJson] } diff --git a/test/spec/Feature/Query/InsertSpec.hs b/test/spec/Feature/Query/InsertSpec.hs index 57622a2235..7a8caddca7 100644 --- a/test/spec/Feature/Query/InsertSpec.hs +++ b/test/spec/Feature/Query/InsertSpec.hs @@ -595,7 +595,7 @@ spec actualPgVersion = do } context "requesting header only representation" $ do - it "returns a location header" $ + it "returns a location header with a composite PK col" $ request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")] [json|{"k1":1,"k2":"test","extra":2}|] `shouldRespondWith` @@ -606,13 +606,13 @@ spec actualPgVersion = do , "Content-Range" <:> "*/*" ] } - it "should not throw and return location header when a PK is null" $ + it "returns location header with a single PK col" $ request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")] [json|{"id":1}|] `shouldRespondWith` "" { matchStatus = 201 , matchHeaders = [ matchHeaderAbsent hContentType - , "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null" + , "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1" , "Content-Range" <:> "*/*" ] }