Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: handle queries on non-existing table gracefully #3869

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #3795, Clarify `Accept: vnd.pgrst.object` error message - @steve-chavez
- #3779, Always log the schema cache load time - @steve-chavez
- #3706, Fix insert with `missing=default` uses default value of domain instead of column - @taimoorzaeem
- #3697, Handle queries on non-existing table gracefully - @taimoorzaeem
Copy link
Member

@steve-chavez steve-chavez Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing the change in the tests output, I think this should also be listed in ### Changed.

This also means that we should release it on the upcoming major.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean you would not consider backpatching this?

Personally, I don't think this needs to be explicitly mentioned. It's an error condition that nobody relies on. This only comes up when they do something wrong - and they get a better error now. So this is hardly breaking any existing workflows, right?

(maybe I missed the relevant test output change, though - I assume it's about the 400 -> 404, from no foreign key relationship -> no table)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean you would not consider backpatching this?

Yeah, I was thinking no backpatch. Just to be extra careful.

So this is hardly breaking any existing workflows, right?

I know some users that rely on table requests with no resource embedding being fast (in presence of slow relationships loads), if that has changed (test mentioned on #3869 (comment)) then it would certainly break existing workflows.


### Changed

Expand Down
4 changes: 4 additions & 0 deletions docs/references/errors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ Related to a :ref:`schema_cache`. Most of the time, these errors are solved by :
| | | in the ``columns`` query parameter is not found. |
| PGRST204 | | |
+---------------+-------------+-------------------------------------------------------------+
| .. _pgrst205: | 404 | Caused when the :ref:`table specified <tables_views>` in |
| | | the URI is not found. |
| PGRST205 | | |
+---------------+-------------+-------------------------------------------------------------+

.. _pgrst3**:

Expand Down
2 changes: 2 additions & 0 deletions src/PostgREST/ApiRequest/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import PostgREST.SchemaCache.Identifiers (FieldName,
import PostgREST.SchemaCache.Relationship (Relationship,
RelationshipsMap)
import PostgREST.SchemaCache.Routine (Routine (..))
import PostgREST.SchemaCache.Table (Table)

import Protolude

Expand Down Expand Up @@ -91,6 +92,7 @@ data ApiRequestError
| UnacceptableSchema [Text]
| UnsupportedMethod ByteString
| ColumnNotFound Text Text
| TableNotFound Text Text [Table]
| GucHeadersError
| GucStatusError
| OffLimitsChangesError Int64 Integer
Expand Down
20 changes: 20 additions & 0 deletions src/PostgREST/Error.hs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
RelationshipsMap)
import PostgREST.SchemaCache.Routine (Routine (..),
RoutineParam (..))
import PostgREST.SchemaCache.Table (Table (..))
import Protolude


Expand Down Expand Up @@ -86,6 +87,7 @@ instance PgrstError ApiRequestError where
status UnsupportedMethod{} = HTTP.status405
status LimitNoOrderError = HTTP.status400
status ColumnNotFound{} = HTTP.status400
status TableNotFound{} = HTTP.status404
status GucHeadersError = HTTP.status500
status GucStatusError = HTTP.status500
status OffLimitsChangesError{} = HTTP.status400
Expand Down Expand Up @@ -253,6 +255,12 @@ instance JSON.ToJSON ApiRequestError where
toJSON (ColumnNotFound relName colName) = toJsonPgrstError
SchemaCacheErrorCode04 ("Could not find the '" <> colName <> "' column of '" <> relName <> "' in the schema cache") Nothing Nothing

toJSON (TableNotFound schemaName relName tbls) = toJsonPgrstError
SchemaCacheErrorCode05
("Could not find relation '" <> schemaName <> "." <> relName <> "' in the schema cache")
Nothing
(JSON.String <$> tableNotFoundHint schemaName relName tbls)

-- |
-- If no relationship is found then:
--
Expand Down Expand Up @@ -350,6 +358,16 @@ noRpcHint schema procName params allProcs overloadedProcs =
| null overloadedProcs = Fuzzy.getOne fuzzySetOfProcs procName
| otherwise = (procName <>) <$> Fuzzy.getOne fuzzySetOfParams (listToText params)

-- |
-- Do a fuzzy search in all tables in the same schema and return closest result
tableNotFoundHint :: Text -> Text -> [Table] -> Maybe Text
tableNotFoundHint schema tblName tblList
= fmap (\tbl -> "Perhaps you meant the relation '" <> schema <> "." <> tbl <> "'") perhapsTable
where
perhapsTable = Fuzzy.getOne fuzzyTableSet tblName
fuzzyTableSet = Fuzzy.fromList [ tableName tbl | tbl <- tblList, tableSchema tbl == schema]


compressedRel :: Relationship -> JSON.Value
-- An ambiguousness error cannot happen for computed relationships TODO refactor so this mempty is not needed
compressedRel ComputedRelationship{} = JSON.object mempty
Expand Down Expand Up @@ -640,6 +658,7 @@ data ErrorCode
| SchemaCacheErrorCode02
| SchemaCacheErrorCode03
| SchemaCacheErrorCode04
| SchemaCacheErrorCode05
-- JWT authentication errors
| JWTErrorCode00
| JWTErrorCode01
Expand Down Expand Up @@ -689,6 +708,7 @@ buildErrorCode code = case code of
SchemaCacheErrorCode02 -> "PGRST202"
SchemaCacheErrorCode03 -> "PGRST203"
SchemaCacheErrorCode04 -> "PGRST204"
SchemaCacheErrorCode05 -> "PGRST205"

JWTErrorCode00 -> "PGRST300"
JWTErrorCode01 -> "PGRST301"
Expand Down
23 changes: 16 additions & 7 deletions src/PostgREST/Plan.hs
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,14 @@ dbActionPlan dbAct conf apiReq sCache = case dbAct of

wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Bool -> Either Error CrudPlan
wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} headersOnly = do
rPlan <- readPlan identifier conf sCache apiRequest
rPlan <- readPlan False identifier conf sCache apiRequest
(handler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
return $ WrappedReadPlan rPlan SQL.Read handler mediaType headersOnly identifier

mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error CrudPlan
mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do
rPlan <- readPlan identifier conf sCache apiRequest
rPlan <- readPlan False identifier conf sCache apiRequest
mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
(handler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
Expand All @@ -173,7 +173,7 @@ callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferenc
proc@Function{..} <- mapLeft ApiRequestError $
findProc identifier paramKeys (dbRoutines sCache) iContentMediaType (invMethod == Inv)
let relIdentifier = QualifiedIdentifier pdSchema (fromMaybe pdName $ Routine.funcTableName proc) -- done so a set returning function can embed other relations
rPlan <- readPlan relIdentifier conf sCache apiRequest
rPlan <- readPlan True relIdentifier conf sCache apiRequest
let args = case (invMethod, iContentMediaType) of
(InvRead _, _) -> DirectArgs $ toRpcParams proc qsParams'
(Inv, MTUrlEncoded) -> DirectArgs $ maybe mempty (toRpcParams proc . payArray) iPayload
Expand Down Expand Up @@ -320,8 +320,8 @@ resolveQueryInputField ctx field = withTextParse ctx $ resolveTypeOrUnknown ctx
-- | Builds the ReadPlan tree on a number of stages.
-- | Adds filters, order, limits on its respective nodes.
-- | Adds joins conditions obtained from resource embedding.
readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregates} SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest =
readPlan :: Bool -> QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
readPlan fromCallPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregates} SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest =
let
-- JSON output format hardcoded for now. In the future we might want to support other output mappings such as CSV.
ctx = ResolverContext dbTables dbRepresentations qi "json"
Expand All @@ -340,7 +340,8 @@ readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregate
addLogicTrees ctx apiRequest =<<
addRanges apiRequest =<<
addOrders ctx apiRequest =<<
addFilters ctx apiRequest (initReadRequest ctx $ QueryParams.qsSelect $ iQueryParams apiRequest)
addFilters ctx apiRequest =<<
searchTable fromCallPlan qi dbTables (initReadRequest ctx $ QueryParams.qsSelect $ iQueryParams apiRequest)

-- Build the initial read plan tree
initReadRequest :: ResolverContext -> [Tree SelectItem] -> ReadPlanTree
Expand Down Expand Up @@ -738,6 +739,14 @@ validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest)
| not aggFunctionsAllowed && any (isJust . csAggFunction) select = Left AggregatesNotAllowed
| otherwise = Node rp <$> traverse (validateAggFunctions aggFunctionsAllowed) forest

-- We only search for the table when readPlan is not called from call plan. This
-- is because we reuse readPlan in callReadPlan to search for function name
searchTable :: Bool -> QualifiedIdentifier -> TablesMap -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
searchTable fromCallPlan qi@QualifiedIdentifier{..} tableMap readPlanTree =
case (fromCallPlan, HM.lookup qi tableMap) of
(False, Nothing) -> Left (TableNotFound qiSchema qiName (HM.elems tableMap))
_ -> Right readPlanTree

addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
addFilters ctx ApiRequest{..} rReq =
foldr addFilterToNode (Right rReq) flts
Expand Down Expand Up @@ -961,7 +970,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
typedColumnsOrError = resolveOrError ctx tbl `traverse` S.toList iColumns

resolveOrError :: ResolverContext -> Maybe Table -> FieldName -> Either ApiRequestError CoercibleField
resolveOrError _ Nothing _ = Left NotFound
resolveOrError ctx Nothing _ = Left $ TableNotFound (qiSchema (qi ctx)) (qiName (qi ctx)) (HM.elems (tables ctx))
resolveOrError ctx (Just table) field =
case resolveTableFieldName table field of
CoercibleField{cfIRType=""} -> Left $ ColumnNotFound (tableName table) field
Expand Down
4 changes: 2 additions & 2 deletions test/io/test_big_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,6 @@ def test_should_not_fail_with_stack_overflow(defaultenv):

with run(env=env, wait_max_seconds=30) as postgrest:
response = postgrest.session.get("/unknown-table?select=unknown-rel(*)")
assert response.status_code == 400
assert response.status_code == 404
data = response.json()
assert data["code"] == "PGRST200"
assert data["code"] == "PGRST205"
7 changes: 3 additions & 4 deletions test/io/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,11 +973,10 @@ def test_log_level(level, defaultenv):
r'- - postgrest_test_anonymous \[.+\] "GET /unknown HTTP/1.1" 404 - "" "python-requests/.+"',
output[2],
)

assert len(output) == 5
assert "Connection" and "is available" in output[3]
assert "Connection" and "is available" in output[4]
assert "Connection" and "is used" in output[5]
assert "Connection" and "is used" in output[6]
assert len(output) == 7
assert "Connection" and "is used" in output[4]
Comment on lines +976 to +979
Copy link
Member

@steve-chavez steve-chavez Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, the change in these lines I don't understand. Why does len(output) change from 7 to 5?



def test_no_pool_connection_required_on_bad_http_logic(defaultenv):
Expand Down
8 changes: 2 additions & 6 deletions test/spec/Feature/ConcurrentSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,8 @@ spec =
it "should not raise 'transaction in progress' error" $
raceTest 10 $
get "/fakefake"
`shouldRespondWith` [json|
{ "hint": null,
"details":null,
"code":"42P01",
"message":"relation \"test.fakefake\" does not exist"
} |]
`shouldRespondWith`
[json| {"code":"PGRST205","details":null,"hint":"Perhaps you meant the relation 'test.factories'","message":"Could not find relation 'test.fakefake' in the schema cache"} |]
{ matchStatus = 404
, matchHeaders = []
}
Expand Down
7 changes: 6 additions & 1 deletion test/spec/Feature/Query/DeleteSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,12 @@ spec =

context "totally unknown route" $
it "fails with 404" $
request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
request methodDelete "/foozle?id=eq.101" [] ""
`shouldRespondWith`
[json| {"code":"PGRST205","details":null,"hint":"Perhaps you meant the relation 'test.foo'","message":"Could not find relation 'test.foozle' in the schema cache"} |]
{ matchStatus = 404
, matchHeaders = []
}

context "table with limited privileges" $ do
it "fails deleting the row when return=representation and selecting all the columns" $
Expand Down
2 changes: 1 addition & 1 deletion test/spec/Feature/Query/InsertSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ spec actualPgVersion = do
{"id": 204, "body": "yyy"},
{"id": 205, "body": "zzz"}]|]
`shouldRespondWith`
[json|{} |]
[json| {"code":"PGRST205","details":null,"hint":"Perhaps you meant the relation 'test.articles'","message":"Could not find relation 'test.garlic' in the schema cache"} |]
{ matchStatus = 404
, matchHeaders = []
}
Expand Down
8 changes: 7 additions & 1 deletion test/spec/Feature/Query/MultipleSchemaSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ spec =
}

it "doesn't find another_table in schema v1" $
request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404
request methodGet "/another_table"
[("Accept-Profile", "v1")] ""
`shouldRespondWith`
[json| {"code":"PGRST205","details":null,"hint":null,"message":"Could not find relation 'v1.another_table' in the schema cache"} |]
{ matchStatus = 404
, matchHeaders = []
}

it "fails trying to read table from unkown schema" $
request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
Expand Down
29 changes: 14 additions & 15 deletions test/spec/Feature/Query/QuerySpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ spec = do

describe "Querying a nonexistent table" $
it "causes a 404" $
get "/faketable" `shouldRespondWith` 404
get "/faketable"
`shouldRespondWith`
[json| {"code":"PGRST205","details":null,"hint":"Perhaps you meant the relation 'test.private_table'","message":"Could not find relation 'test.faketable' in the schema cache"} |]
{ matchStatus = 404
, matchHeaders = []
}

describe "Filtering response" $ do
it "matches with equality" $
Expand Down Expand Up @@ -617,14 +622,12 @@ spec = do
, matchHeaders = [matchContentTypeJson]
}

it "cannot request a partitioned table as parent from a partition" $
-- we only search for foreign key relationships after checking the
-- the existence of first table, #3869
it "table not found error if first table does not exist" $
get "/car_model_sales_202101?select=id,name,car_models(id,name)&order=id.asc" `shouldRespondWith`
[json|
{"hint":"Perhaps you meant 'car_model_sales' instead of 'car_model_sales_202101'.",
"details":"Searched for a foreign key relationship between 'car_model_sales_202101' and 'car_models' in the schema 'test', but no matches were found.",
"code":"PGRST200",
"message":"Could not find a relationship between 'car_model_sales_202101' and 'car_models' in the schema cache"} |]
{ matchStatus = 400
[json| {"code":"PGRST205","details":null,"hint":"Perhaps you meant the relation 'test.car_model_sales'","message":"Could not find relation 'test.car_model_sales_202101' in the schema cache"} |]
{ matchStatus = 404
, matchHeaders = [matchContentTypeJson]
}

Expand All @@ -639,14 +642,10 @@ spec = do
, matchHeaders = [matchContentTypeJson]
}

it "cannot request partitioned tables as children from a partition" $
it "table not found error if first table does not exist" $
get "/car_models_default?select=id,name,car_model_sales(id,name)&order=id.asc" `shouldRespondWith`
[json|
{"hint":"Perhaps you meant 'car_model_sales' instead of 'car_models_default'.",
"details":"Searched for a foreign key relationship between 'car_models_default' and 'car_model_sales' in the schema 'test', but no matches were found.",
"code":"PGRST200",
"message":"Could not find a relationship between 'car_models_default' and 'car_model_sales' in the schema cache"} |]
{ matchStatus = 400
[json| {"code":"PGRST205","details":null,"hint":"Perhaps you meant the relation 'test.car_model_sales'","message":"Could not find relation 'test.car_models_default' in the schema cache"} |]
{ matchStatus = 404
, matchHeaders = [matchContentTypeJson]
}

Expand Down
9 changes: 7 additions & 2 deletions test/spec/Feature/Query/UpdateSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ spec = do
it "indicates no table found by returning 404" $
request methodPatch "/fake" []
[json| { "real": false } |]
`shouldRespondWith` 404
`shouldRespondWith`
[json| {"code":"PGRST205","details":null,"hint":"Perhaps you meant the relation 'test.factories'","message":"Could not find relation 'test.fake' in the schema cache"} |]
{ matchStatus = 404
, matchHeaders = []
}


context "on an empty table" $
it "succeeds with status code 204" $
Expand Down Expand Up @@ -343,7 +348,7 @@ spec = do
{"id": 204, "body": "yyy"},
{"id": 205, "body": "zzz"}]|]
`shouldRespondWith`
[json|{} |]
[json| {"code":"PGRST205","details":null,"hint":"Perhaps you meant the relation 'test.articles'","message":"Could not find relation 'test.garlic' in the schema cache"} |]
{ matchStatus = 404
, matchHeaders = []
}
Expand Down