diff --git a/CHANGELOG.md b/CHANGELOG.md index 5749b4f93..37605b85c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. From versio ## Unreleased +### Fixed + +- The OpenAPI output now reflects table privileges: only the granted HTTP methods are exposed (e.g. `SELECT` grants `GET`, `INSERT` grants `POST`) and column-level grants filter the columns shown on table definitions and row filters. + ## [16.1] - 2026-08-10 ### Fixed diff --git a/docs/references/api/openapi.rst b/docs/references/api/openapi.rst index ec546eb72..c32855837 100644 --- a/docs/references/api/openapi.rst +++ b/docs/references/api/openapi.rst @@ -9,6 +9,8 @@ PostgREST automatically serves a full `OpenAPI `_ des By default, this output depends on the permissions of the role that is contained in the JWT role claim (or the :ref:`db-anon-role` if no JWT is sent). If you need to show all the endpoints disregarding the role's permissions, set the :ref:`openapi-mode` config to :code:`ignore-privileges`. + When following privileges, the output reflects both the granted HTTP methods and columns: a relation with only ``SELECT`` will only expose ``GET``, a relation with only ``INSERT`` will only expose ``POST``, and column-level grants limit the columns shown on the table definitions and row filters. + For extra customization, the OpenAPI output contains a "description" field for every `SQL comment `_ on any database object. For instance, .. code-block:: postgres diff --git a/postgrest.cabal b/postgrest.cabal index 835a812bc..4368d62f3 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -78,6 +78,7 @@ library PostgREST.Network PostgREST.Observation PostgREST.Query + PostgREST.Query.OpenApi PostgREST.Query.PreQuery PostgREST.Query.QueryBuilder PostgREST.Query.SqlFragment diff --git a/src/library/PostgREST/MainTx.hs b/src/library/PostgREST/MainTx.hs index 30f02d441..0a3f6efbb 100644 --- a/src/library/PostgREST/MainTx.hs +++ b/src/library/PostgREST/MainTx.hs @@ -19,7 +19,6 @@ import qualified Data.Aeson.Lens as L import qualified Data.ByteString as BS hiding (break) import qualified Data.ByteString.Char8 as BS import qualified Data.HashMap.Strict as HM -import qualified Data.Set as S import qualified Hasql.Decoders as HD import qualified Hasql.DynamicStatements.Statement as SQL import qualified Hasql.Session as SQL (Session) @@ -44,6 +43,7 @@ import PostgREST.Plan (ActionPlan (..), CrudPlan (..), DbActionPlan (..), InfoPlan (..), InspectPlan (..)) import PostgREST.Query (MainQuery (..)) +import PostgREST.Query.OpenApi (TableAccess (..), TablesAccess) import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Routine (Routine (..), RoutineMap) @@ -60,7 +60,7 @@ data MainTx data DbResult = DbCrudResult CrudPlan ResultSet | DbPlanResult MediaType BS.ByteString - | MaybeDbResult InspectPlan (Maybe (TablesMap, RoutineMap, Maybe Text)) + | MaybeDbResult InspectPlan (Maybe (TablesMap, TablesAccess, RoutineMap, Maybe Text)) | NoDbResult InfoPlan -- | Standard result set format used for the mqMain query @@ -174,33 +174,36 @@ actionResult MainQuery{mqOpenAPI=(tblsQ, funcsQ, schQ)} (MayUseDb plan@InspectPl mainActionQuery = lift $ case configOpenApiMode of OAFollowPriv -> do - tableAccess <- SQL.statement mempty $ SQL.dynamicallyParameterized tblsQ decodeAccessibleIdentifiers configDbPreparedStatements + tableAccess <- SQL.statement mempty $ SQL.dynamicallyParameterized tblsQ decodeTablesAccess configDbPreparedStatements accFuncs <- SQL.statement mempty $ SQL.dynamicallyParameterized funcsQ SchemaCache.decodeFuncs configDbPreparedStatements schDesc <- SQL.statement mempty $ SQL.dynamicallyParameterized schQ decodeSchemaDesc configDbPreparedStatements - let tbls = HM.filterWithKey (\qi _ -> S.member qi tableAccess) $ SchemaCache.dbTables sCache + let tbls = HM.filterWithKey (\qi _ -> HM.member qi tableAccess) $ SchemaCache.dbTables sCache - pure $ MaybeDbResult plan (Just (tbls, accFuncs, schDesc)) + pure $ MaybeDbResult plan (Just (tbls, tableAccess, accFuncs, schDesc)) OAIgnorePriv -> do schDesc <- SQL.statement mempty (SQL.dynamicallyParameterized schQ decodeSchemaDesc configDbPreparedStatements) let tbls = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) (SchemaCache.dbTables sCache) routs = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) (SchemaCache.dbRoutines sCache) - pure $ MaybeDbResult plan (Just (tbls, routs, schDesc)) + pure $ MaybeDbResult plan (Just (tbls, mempty, routs, schDesc)) OADisabled -> pure $ MaybeDbResult plan Nothing decodeSchemaDesc :: HD.Result (Maybe Text) decodeSchemaDesc = join <$> HD.rowMaybe (nullableColumn HD.text) - decodeAccessibleIdentifiers :: HD.Result (S.Set QualifiedIdentifier) - decodeAccessibleIdentifiers = + decodeTablesAccess :: HD.Result TablesAccess + decodeTablesAccess = let - row = QualifiedIdentifier - <$> column HD.text - <*> column HD.text + row = (,) <$> (QualifiedIdentifier <$> column HD.text <*> column HD.text) + <*> (TableAccess + <$> arrayColumn HD.text + <*> arrayColumn HD.text + <*> arrayColumn HD.text + <*> column HD.bool) in - S.fromList <$> HD.rowList row + HM.fromList <$> HD.rowList row -- Makes sure the querystring pk matches the payload pk -- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, diff --git a/src/library/PostgREST/Query/OpenApi.hs b/src/library/PostgREST/Query/OpenApi.hs new file mode 100644 index 000000000..3ee046aa8 --- /dev/null +++ b/src/library/PostgREST/Query/OpenApi.hs @@ -0,0 +1,29 @@ +{-| +Module : PostgREST.Query.OpenApi +Description : Types for reflecting the role privileges on the OpenAPI output. +-} +module PostgREST.Query.OpenApi + ( TableAccess (..) + , TablesAccess + ) where + +import qualified Data.HashMap.Strict as HM + +import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier) + +import Protolude + +-- | Privileges that a role has on a relation, used to reflect them on the OpenAPI output. +data TableAccess = TableAccess + { taSelectCols :: [FieldName] + -- ^ columns the role can SELECT + , taInsertCols :: [FieldName] + -- ^ columns the role can INSERT into + , taUpdateCols :: [FieldName] + -- ^ columns the role can UPDATE + , taDelete :: Bool + -- ^ whether the role can DELETE rows + } + deriving (Show, Eq) + +type TablesAccess = HM.HashMap QualifiedIdentifier TableAccess diff --git a/src/library/PostgREST/Query/SqlFragment.hs b/src/library/PostgREST/Query/SqlFragment.hs index 973dabab5..842ae7189 100644 --- a/src/library/PostgREST/Query/SqlFragment.hs +++ b/src/library/PostgREST/Query/SqlFragment.hs @@ -598,7 +598,32 @@ accessibleTables :: Text -> SQL.Snippet accessibleTables schema = SQL.sql (encodeUtf8 [trimming| SELECT n.nspname AS table_schema, - c.relname AS table_name + c.relname AS table_name, + COALESCE(( + SELECT array_agg(a.attname ORDER BY a.attnum) + FROM pg_attribute a + WHERE a.attrelid = c.oid + AND a.attnum > 0 + AND NOT a.attisdropped + AND has_column_privilege(c.oid, a.attnum, 'SELECT') + ), '{}') AS select_cols, + COALESCE(( + SELECT array_agg(a.attname ORDER BY a.attnum) + FROM pg_attribute a + WHERE a.attrelid = c.oid + AND a.attnum > 0 + AND NOT a.attisdropped + AND has_column_privilege(c.oid, a.attnum, 'INSERT') + ), '{}') AS insert_cols, + COALESCE(( + SELECT array_agg(a.attname ORDER BY a.attnum) + FROM pg_attribute a + WHERE a.attrelid = c.oid + AND a.attnum > 0 + AND NOT a.attisdropped + AND has_column_privilege(c.oid, a.attnum, 'UPDATE') + ), '{}') AS update_cols, + has_table_privilege(c.oid, 'DELETE') AS has_delete FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('v','r','m','f','p') diff --git a/src/library/PostgREST/Response.hs b/src/library/PostgREST/Response.hs index 89f431a98..a02d5caaa 100644 --- a/src/library/PostgREST/Response.hs +++ b/src/library/PostgREST/Response.hs @@ -202,7 +202,7 @@ actionResponse (DbPlanResult media plan) ctxApiRequest _ _ _ = actionResponse (MaybeDbResult InspectPlan{ipHdrsOnly=headersOnly} body) ApiRequest{..} versions conf sCache = let - rsBody = maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache x y z) body + rsBody = maybe mempty (\(tbls, tblAccess, procs, schDesc) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache tbls tblAccess procs schDesc) body cLHeader = if headersOnly then mempty else [contentLengthHeader rsBody] in Right $ PgrstResponse HTTP.status200 (MediaType.toContentType MTOpenAPI : cLHeader ++ maybeToList (profileHeader iSchema iNegotiatedByProfile)) rsBody diff --git a/src/library/PostgREST/Response/OpenAPI.hs b/src/library/PostgREST/Response/OpenAPI.hs index c7cb2748b..9276b7d50 100644 --- a/src/library/PostgREST/Response/OpenAPI.hs +++ b/src/library/PostgREST/Response/OpenAPI.hs @@ -27,8 +27,9 @@ import PostgREST.Config (AppConfig (..), Proxy (..), isMalformedProxyUri, toURI) import PostgREST.MediaType import PostgREST.Network (escapeHostName) +import PostgREST.Query.OpenApi (TableAccess (..), TablesAccess) import PostgREST.SchemaCache (SchemaCache (..)) -import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) +import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier (..)) import PostgREST.SchemaCache.Relationship (Cardinality (..), Relationship (..), RelationshipsMap) import PostgREST.SchemaCache.Routine (FuncVolatility (..), Routine (..), @@ -38,18 +39,27 @@ import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap, import Protolude hiding (Proxy, get) -encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString -encode versions conf sCache tables procs schemaDescription = +encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> TablesAccess -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString +encode versions conf sCache tables access procs schemaDescription = JSON.encode $ postgrestSpec versions (dbRelationships sCache) (concat $ HM.elems procs) - (snd <$> HM.toList tables) + (fmap (\(_, t) -> (t, accessFor access t)) (HM.toList tables)) (proxyUri conf) schemaDescription (configOpenApiSecurityActive conf) +-- | Get the access privileges for a table. When the table is not present in the +-- map(ignore-privileges mode), assume the role has full access to it. +accessFor :: TablesAccess -> Table -> TableAccess +accessFor access t = + fromMaybe fullAccess (HM.lookup (QualifiedIdentifier (tableSchema t) (tableName t)) access) + where + fullAccess = TableAccess allCols allCols allCols True + allCols = colName <$> tableColumnsList t + makeMimeList :: [MediaType] -> MimeList makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs @@ -97,14 +107,19 @@ parseDefault colType colDefault = where wrapInQuotations text = "\"" <> text <> "\"" -makeTableDef :: RelationshipsMap -> Table -> (Text, Schema) -makeTableDef rels t = - let tn = tableName t in - (tn, (mempty :: Schema) - & description .~ tableDescription t - & type_ ?~ SwaggerObject - & properties .~ fromList (makeProperty t rels <$> tableColumnsList t) - & required .~ fmap colName (filter (not . colNullable) $ tableColumnsList t)) +makeTableDef :: RelationshipsMap -> (Table, TableAccess) -> (Text, Schema) +makeTableDef rels (t, access) = + (tn, (mempty :: Schema) + & description .~ tableDescription t + & type_ ?~ SwaggerObject + & properties .~ fromList (makeProperty t rels <$> cols) + & required .~ fmap colName (filter (not . colNullable) cols)) + where + tn = tableName t + cols = accessibleCols t (taSelectCols access) + +accessibleCols :: Table -> [FieldName] -> [Column] +accessibleCols t cols = filter ((`elem` cols) . colName) (tableColumnsList t) makeProperty :: Table -> RelationshipsMap -> Column -> (Text, Referenced Schema) makeProperty tbl rels col = (colName col, Inline s) @@ -222,8 +237,8 @@ makeProcPostParams pd = , Ref $ Reference "preferParams" ] -makeParamDefs :: [Table] -> [(Text, Param)] -makeParamDefs ti = +makeParamDefs :: RelationshipsMap -> [(Table, TableAccess)] -> [(Text, Param)] +makeParamDefs rels tis = -- TODO: create Prefer for each method (GET, PATCH, etc.) [ ("preferParams", makePreferParam ["params"]) , ("preferReturn", makePreferParam ["return"]) @@ -280,17 +295,27 @@ makeParamDefs ti = & in_ .~ ParamQuery & type_ ?~ SwaggerString)) ] - <> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) (tableColumnsList t) - | t <- ti + <> concat [ makeObjectBody rels t access <> makeRowFilters (tableName t) (accessibleCols t (taSelectCols access)) + | (t, access) <- tis ] -makeObjectBody :: Text -> (Text, Param) -makeObjectBody tn = - ("body." <> tn, (mempty :: Param) - & name .~ tn - & description ?~ tn - & required ?~ False - & schema .~ ParamBody (Ref (Reference tn))) +makeObjectBody :: RelationshipsMap -> Table -> TableAccess -> [(Text, Param)] +makeObjectBody rels t access = + [ ("body." <> tn, makeBodyParam (taInsertCols access)) + , ("body." <> tn <> ".patch", makeBodyParam (taUpdateCols access)) + ] + where + tn = tableName t + makeBodyParam cols = (mempty :: Param) + & name .~ tn + & description ?~ tn + & required ?~ False + & schema .~ ParamBody (Inline bodySchema) + where + bodySchema = (mempty :: Schema) + & type_ ?~ SwaggerObject + & properties .~ fromList (makeProperty t rels <$> accessibleCols t cols) + & required .~ fmap colName (filter (not . colNullable) (accessibleCols t cols)) makeRowFilter :: Text -> Column -> (Text, Param) makeRowFilter tn c = @@ -305,8 +330,8 @@ makeRowFilter tn c = makeRowFilters :: Text -> [Column] -> [(Text, Param)] makeRowFilters tn = fmap (makeRowFilter tn) -makePathItem :: Table -> (FilePath, PathItem) -makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t || tableDeletable t) +makePathItem :: (Table, TableAccess) -> (FilePath, PathItem) +makePathItem (t, access) = ("/" ++ T.unpack tn, p) 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 @@ -327,20 +352,26 @@ makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t ) ) postOp = tOp - & parameters .~ fmap ref ["body." <> tn, "select", "preferPost"] + & parameters .~ fmap ref [bodyParam, "select", "preferPost"] & at 201 ?~ "Created" patchOp = tOp - & parameters .~ fmap ref (rs <> ["body." <> tn, "preferReturn"]) + & parameters .~ fmap ref (rs <> [patchBodyParam, "preferReturn"]) & at 204 ?~ "No Content" deletOp = tOp & parameters .~ fmap ref (rs <> ["preferReturn"]) & at 204 ?~ "No Content" - pr = (mempty :: PathItem) & get ?~ getOp - pw = pr & post ?~ postOp & patch ?~ patchOp & delete ?~ deletOp - p False = pr - p True = pw + p = (mempty :: PathItem) + & get .~ (if not (null selCols) then Just getOp else Nothing) + & post .~ (if tableInsertable t && not (null insCols) then Just postOp else Nothing) + & patch .~ (if tableUpdatable t && not (null updCols) then Just patchOp else Nothing) + & delete .~ (if tableDeletable t && taDelete access then Just deletOp else Nothing) tn = tableName t - rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- tableColumnsList t ] + selCols = accessibleCols t (taSelectCols access) + insCols = accessibleCols t (taInsertCols access) + updCols = accessibleCols t (taUpdateCols access) + rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- selCols ] + bodyParam = "body." <> tn + patchBodyParam = "body." <> tn <> ".patch" ref = Ref . Reference makeProcPathItem :: Routine -> (FilePath, PathItem) @@ -375,9 +406,9 @@ makeRootPathItem = ("/", p) pr = (mempty :: PathItem) & get ?~ getOp p = pr -makePathItems :: [Routine] -> [Table] -> InsOrdHashMap FilePath PathItem -makePathItems pds ti = fromList $ makeRootPathItem : - fmap makePathItem ti ++ fmap makeProcPathItem pds +makePathItems :: [Routine] -> [(Table, TableAccess)] -> InsOrdHashMap FilePath PathItem +makePathItems pds tis = fromList $ makeRootPathItem : + fmap makePathItem tis ++ fmap makeProcPathItem pds makeSecurityDefinitions :: Text -> Bool -> SecurityDefinitions makeSecurityDefinitions secName allow @@ -387,8 +418,8 @@ makeSecurityDefinitions secName allow secSchType = SecuritySchemeApiKey (ApiKeyParams "Authorization" ApiKeyHeader) secSchDescription = Just "Add the token prepending \"Bearer \" (without quotes) to it" -postgrestSpec :: (Text, Text) -> RelationshipsMap -> [Routine] -> [Table] -> (Text, Text, Integer, Text) -> Maybe Text -> Bool -> Swagger -postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecurityDef = (mempty :: Swagger) +postgrestSpec :: (Text, Text) -> RelationshipsMap -> [Routine] -> [(Table, TableAccess)] -> (Text, Text, Integer, Text) -> Maybe Text -> Bool -> Swagger +postgrestSpec (prettyVersion, docsVersion) rels pds tis (s, h, p, b) sd allowSecurityDef = (mempty :: Swagger) & basePath ?~ T.unpack b & schemes ?~ [s'] & info .~ ((mempty :: Info) @@ -399,9 +430,9 @@ postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecu & description ?~ "PostgREST Documentation" & url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/references/api.html")) & host .~ h' - & definitions .~ fromList (makeTableDef rels <$> ti) - & parameters .~ fromList (makeParamDefs ti) - & paths .~ makePathItems pds ti + & definitions .~ fromList (makeTableDef rels <$> tis) + & parameters .~ fromList (makeParamDefs rels tis) + & paths .~ makePathItems pds tis & produces .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV] & consumes .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV] & securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef diff --git a/test/spec/Feature/OpenApi/OpenApiSpec.hs b/test/spec/Feature/OpenApi/OpenApiSpec.hs index cd98090e1..694a30e96 100644 --- a/test/spec/Feature/OpenApi/OpenApiSpec.hs +++ b/test/spec/Feature/OpenApi/OpenApiSpec.hs @@ -222,6 +222,58 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do . nth 0 liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|] + it "reflects table privileges in the HTTP methods" $ do + r <- simpleBody <$> get "/" + + let selectonlyGet = r ^? key "paths" . key "/selectonly" . key "get" + selectonlyPost = r ^? key "paths" . key "/selectonly" . key "post" + insertonlyGet = r ^? key "paths" . key "/insertonly" . key "get" + insertonlyPost = r ^? key "paths" . key "/insertonly" . key "post" + insertonlyDelete = r ^? key "paths" . key "/insertonly" . key "delete" + limitedStarsGet = r ^? key "paths" . key "/limited_article_stars" . key "get" + limitedStarsPost = r ^? key "paths" . key "/limited_article_stars" . key "post" + limitedStarsPatch = r ^? key "paths" . key "/limited_article_stars" . key "patch" + limitedStarsDelete = r ^? key "paths" . key "/limited_article_stars" . key "delete" + + liftIO $ do + selectonlyGet `shouldNotBe` Nothing + selectonlyPost `shouldBe` Nothing + + insertonlyGet `shouldBe` Nothing + insertonlyPost `shouldNotBe` Nothing + insertonlyDelete `shouldBe` Nothing + + limitedStarsGet `shouldNotBe` Nothing + limitedStarsPost `shouldNotBe` Nothing + limitedStarsPatch `shouldNotBe` Nothing + limitedStarsDelete `shouldBe` Nothing + + it "reflects column privileges in the table definition" $ do + r <- simpleBody <$> get "/" + + let appUsersId = r ^? key "definitions" . key "app_users" . key "properties" . key "id" + appUsersEmail = r ^? key "definitions" . key "app_users" . key "properties" . key "email" + appUsersPassword = r ^? key "definitions" . key "app_users" . key "properties" . key "password" + appUsersRequired = r ^? key "definitions" . key "app_users" . key "required" + + liftIO $ do + appUsersId `shouldNotBe` Nothing + appUsersEmail `shouldNotBe` Nothing + appUsersPassword `shouldBe` Nothing + appUsersRequired `shouldBe` Just [aesonQQ|["id", "email"]|] + + it "reflects column privileges in the rowFilter parameters" $ do + r <- simpleBody <$> get "/" + + let filterId = r ^? key "parameters" . key "rowFilter.app_users.id" + filterEmail = r ^? key "parameters" . key "rowFilter.app_users.email" + filterPassword = r ^? key "parameters" . key "rowFilter.app_users.password" + + liftIO $ do + filterId `shouldNotBe` Nothing + filterEmail `shouldNotBe` Nothing + filterPassword `shouldBe` Nothing + it "includes a fk description for a O2O relationship" $ do r <- simpleBody <$> get "/" diff --git a/test/spec/fixtures/privileges.sql b/test/spec/fixtures/privileges.sql index 93e05ea49..e90b85a57 100644 --- a/test/spec/fixtures/privileges.sql +++ b/test/spec/fixtures/privileges.sql @@ -28,10 +28,13 @@ REVOKE ALL PRIVILEGES ON TABLE , authors_only , insertonly , limited_article_stars + , selectonly FROM postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; +GRANT SELECT ON TABLE selectonly TO postgrest_test_anonymous; + GRANT USAGE ON SEQUENCE auto_incrementing_pk_id_seq , items_id_seq diff --git a/test/spec/fixtures/schema.sql b/test/spec/fixtures/schema.sql index cce6136e8..f60491b14 100644 --- a/test/spec/fixtures/schema.sql +++ b/test/spec/fixtures/schema.sql @@ -1926,6 +1926,11 @@ create table app_users ( password text not null ); +create table selectonly ( + id integer primary key, + name text +); + create table private.pages ( link int not null unique , url text