Skip to content
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
19 changes: 3 additions & 16 deletions src/library/PostgREST/Error.hs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ Module : PostgREST.Error
Description : PostgREST error HTTP responses
-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}

module PostgREST.Error
Expand Down Expand Up @@ -45,7 +44,6 @@ import PostgREST.MediaType (MediaType (..))
import qualified PostgREST.MediaType as MediaType

import PostgREST.Config (Verbosity (..))
import PostgREST.SchemaCache (SchemaCache (SchemaCache, dbTablesFuzzyIndex))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..), Schema)
import PostgREST.SchemaCache.Relationship (Cardinality (..), Junction (..),
Relationship (..), RelationshipsMap)
Expand Down Expand Up @@ -252,7 +250,7 @@ instance ErrorBody SchemaCacheError where
fmtPrms p = if null argumentKeys then " without parameters" else p
message (AmbiguousRpc procs) = "Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs]
message (ColumnNotFound rel col) = "Could not find the '" <> col <> "' column of '" <> rel <> "' in the schema cache"
message (TableNotFound schemaName relName _) = "Could not find the table '" <> schemaName <> "." <> relName <> "' in the schema cache"
message (TableNotFound qi) = "Could not find the table '" <> qiSchema qi <> "." <> qiName qi <> "' in the schema cache"

details (NoRelBetween parent child embedHint schema _) = Just $ JSON.String $ "Searched for a foreign key relationship between '" <> parent <> "' and '" <> child <> maybe mempty ("' using the hint '" <>) embedHint <> "' in the schema '" <> schema <> "', but no matches were found."
details (AmbiguousRelBetween _ _ rels) = Just $ JSON.toJSONList (compressedRel <$> rels)
Expand Down Expand Up @@ -283,7 +281,6 @@ instance ErrorBody SchemaCacheError where
where
onlySingleParams = isInvPost && contentType `elem` [MTTextPlain, MTTextXML, MTOctetStream]
hint (AmbiguousRpc _) = Just "Try renaming the parameters or the function itself in the database so function overloading can be resolved"
hint (TableNotFound schemaName relName schemaCache) = JSON.String <$> tableNotFoundHint schemaName relName schemaCache

hint _ = Nothing

Expand Down Expand Up @@ -381,25 +378,15 @@ noRpcHint schema procName params allProcs overloadedProcs =
| null overloadedProcs = getFuzzyHint HintProcedure fuzzySetOfProcs procName
| otherwise = (procName <>) <$> getFuzzyHint HintParams fuzzySetOfParams (listToText params)

-- |
-- Do a fuzzy search in all tables in the same schema and return closest result
tableNotFoundHint :: Text -> Text -> SchemaCache -> Maybe Text
tableNotFoundHint schema tblName SchemaCache{dbTablesFuzzyIndex}
= fmap (\tbl -> "Perhaps you meant the table '" <> schema <> "." <> tbl <> "'") perhapsTable
where
perhapsTable = (\fuzzySet -> getFuzzyHint HintTable fuzzySet tblName) =<< HM.lookup schema dbTablesFuzzyIndex

data HintType
= HintTable
| HintProcedure
= HintProcedure
| HintParams

-- | Get hint using Fuzzy Search with at least 0.75 similarity score
getFuzzyHint :: HintType -> Fuzzy.FuzzySet -> Text -> Maybe Text
getFuzzyHint hintType =
let minScore = 0.75 :: Double -- used for table and procedure name hints
let minScore = 0.75 :: Double -- used for procedure name hints
in case hintType of
HintTable -> Fuzzy.getOneWithMinScore minScore
HintProcedure -> Fuzzy.getOneWithMinScore minScore
HintParams -> Fuzzy.getOne -- For params, we stick to `getOne` which defaults to 0.33 min score, not a security risk to reveal params

Expand Down
3 changes: 1 addition & 2 deletions src/library/PostgREST/Error/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ module PostgREST.Error.Types
import qualified Hasql.Pool as SQL

import PostgREST.MediaType (MediaType (..))
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Relationship (..), RelationshipsMap)
import PostgREST.SchemaCache.Routine (Routine (..))
Expand Down Expand Up @@ -84,7 +83,7 @@ data SchemaCacheError
| NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
| NoRpc Text Text [Text] MediaType Bool [QualifiedIdentifier] [Routine]
| ColumnNotFound Text Text
| TableNotFound Text Text SchemaCache
| TableNotFound QualifiedIdentifier
deriving Show

-- JWT ERRORS: PGRST3XX
Expand Down
38 changes: 14 additions & 24 deletions src/library/PostgREST/Plan.hs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import qualified PostgREST.SchemaCache.Routine as Routine

import Data.Either.Combinators (mapLeft, mapRight)
import Data.List (delete, lookup)
import Data.Maybe (fromJust)
import Data.Tree (Tree (..))

import PostgREST.ApiRequest (ApiRequest (..))
Expand Down Expand Up @@ -193,20 +192,18 @@ 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
qi <- findTable identifier sCache
rPlan <- readPlan qi conf sCache apiRequest
(handler, mediaType) <- mapLeft ApiRequestErr $ negotiateContent conf apiRequest qi iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
rPlan <- readPlan identifier conf sCache apiRequest
(handler, mediaType) <- mapLeft ApiRequestErr $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestErr $ InvalidPreferences invalidPrefs else Right ()
return $ WrappedReadPlan rPlan SQL.Read handler mediaType headersOnly qi
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
qi <- findTable identifier sCache
rPlan <- readPlan qi conf sCache apiRequest
mPlan <- mutatePlan mutation qi apiRequest sCache rPlan
rPlan <- readPlan identifier conf sCache apiRequest
mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestErr $ InvalidPreferences invalidPrefs else Right ()
(handler, mediaType) <- mapLeft ApiRequestErr $ negotiateContent conf apiRequest qi iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
return $ MutateReadPlan rPlan mPlan SQL.Write handler mediaType mutation qi
(handler, mediaType) <- mapLeft ApiRequestErr $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
return $ MutateReadPlan rPlan mPlan SQL.Write handler mediaType mutation identifier

callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CrudPlan
callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{preferHandling, invalidPrefs, preferMaxAffected},..} invMethod = do
Expand Down Expand Up @@ -832,13 +829,6 @@ validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest)
| not aggFunctionsAllowed && any (isJust . csAggFunction) select = Left $ ApiRequestErr AggregatesNotAllowed
| otherwise = Node rp <$> traverse (validateAggFunctions aggFunctionsAllowed) forest

-- | Lookup table in the schema cache before creating read plan
findTable :: QualifiedIdentifier -> SchemaCache -> Either Error QualifiedIdentifier
findTable qi@QualifiedIdentifier{..} sc@SchemaCache{dbTables} =
case HM.lookup qi dbTables of
Nothing -> Left $ SchemaCacheErr $ TableNotFound qiSchema qiName sc
Just _ -> Right qi

addFilters :: ResolverContext -> ApiRequest -> Bool -> ReadPlanTree -> Either Error ReadPlanTree
addFilters ctx ApiRequest{..} useTargetNames rReq =
foldr addFilterToNode (Right rReq) flts
Expand Down Expand Up @@ -1066,18 +1056,18 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
if preferRepresentation == Just None || isNothing preferRepresentation
then []
else S.toList $ inferColsEmbedNeeds readReq pkCols
-- TODO: remove fromJust by refactoring later
-- we can use fromJust, we have already looked up the table before building mutatePlan
tbl = fromJust $ HM.lookup qi dbTables
pkCols = maybe mempty tablePKCols (Just tbl)
tbl = HM.lookup qi dbTables
pkCols = maybe mempty tablePKCols tbl
logic = map (resolveLogicTree ctx . snd) qsLogic
combinedLogic = foldr (addFilterToLogicForest . resolveFilter ctx) logic qsFiltersRoot
body = payRaw <$> iPayload -- the body is assumed to be json at this stage(ApiRequest validates)
applyDefaults = preferMissing == Just ApplyDefaults
typedColumnsOrError = resolveOrError ctx tbl `traverse` S.toList iColumns
typedColumnsOrError = resolveOrError ctx tbl qi `traverse` S.toList iColumns

resolveOrError :: ResolverContext -> Table -> FieldName -> Either Error CoercibleField
resolveOrError ctx table field = case resolveTableFieldName table field Nothing of
resolveOrError :: ResolverContext -> Maybe Table -> QualifiedIdentifier -> FieldName -> Either Error CoercibleField
resolveOrError _ Nothing qi _ = Left $ SchemaCacheErr $ TableNotFound qi
resolveOrError ctx (Just table) _ field =
case resolveTableFieldName table field Nothing of
CoercibleField{cfIRType=""} -> Left $ SchemaCacheErr $ ColumnNotFound (tableName table) field
cf -> Right $ withJsonParse ctx cf

Expand Down
4 changes: 2 additions & 2 deletions src/library/PostgREST/Response.hs
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,10 @@ actionResponse (MaybeDbResult InspectPlan{ipHdrsOnly=headersOnly} body) ApiReque
in
Right $ PgrstResponse HTTP.status200 (MediaType.toContentType MTOpenAPI : cLHeader ++ maybeToList (profileHeader iSchema iNegotiatedByProfile)) rsBody

actionResponse (NoDbResult (RelInfoPlan qi@QualifiedIdentifier{..})) _ _ _ sc@SchemaCache{dbTables} =
actionResponse (NoDbResult (RelInfoPlan qi)) _ _ _ SchemaCache{dbTables} =
case HM.lookup qi dbTables of
Just tbl -> respondInfo $ allowH tbl
Nothing -> Left $ Error.SchemaCacheErr $ Error.TableNotFound qiSchema qiName sc
Nothing -> Left $ Error.SchemaCacheErr $ Error.TableNotFound qi
where
allowH table =
let hasPK = not . null $ tablePKCols table in
Expand Down
29 changes: 8 additions & 21 deletions src/library/PostgREST/SchemaCache.hs
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,16 @@ import Protolude
type TablesFuzzyIndex = HM.HashMap Schema Fuzzy.FuzzySet

data SchemaCache = SchemaCache
{ dbTables :: TablesMap
, dbRelationships :: RelationshipsMap
, dbRoutines :: RoutineMap
, dbRepresentations :: RepresentationsMap
, dbMediaHandlers :: MediaHandlerMap
, dbTimezones :: TimezoneNames
-- Memoized fuzzy index of table names per schema to support approximate matching
-- Since index construction can be expensive, we build it once and store in the SchemaCache
-- Haskell lazy evaluation ensures it's only built on first use and memoized afterwards
, dbTablesFuzzyIndex :: TablesFuzzyIndex
{ dbTables :: TablesMap
, dbRelationships :: RelationshipsMap
, dbRoutines :: RoutineMap
, dbRepresentations :: RepresentationsMap
, dbMediaHandlers :: MediaHandlerMap
, dbTimezones :: TimezoneNames
} deriving (Show)

instance JSON.ToJSON SchemaCache where
toJSON (SchemaCache tabs rels routs reps hdlers tzs _) = JSON.object [
toJSON (SchemaCache tabs rels routs reps hdlers tzs) = JSON.object [
"dbTables" .= JSON.toJSON tabs
, "dbRelationships" .= JSON.toJSON rels
, "dbRoutines" .= JSON.toJSON routs
Expand All @@ -95,7 +91,7 @@ instance JSON.ToJSON SchemaCache where
]

showSummary :: SchemaCache -> Text
showSummary (SchemaCache tbls rels routs reps mediaHdlrs tzs _) =
showSummary (SchemaCache tbls rels routs reps mediaHdlrs tzs) =
T.intercalate ", "
[ show (HM.size tbls) <> " Relations"
, show (HM.size rels) <> " Relationships"
Expand Down Expand Up @@ -143,9 +139,6 @@ data KeyDep
-- | A SQL query that can be executed independently
type SqlQuery = ByteString

maxDbTablesForFuzzySearch :: Int
maxDbTablesForFuzzySearch = 500

querySchemaCache :: AppConfig -> SQL.Transaction (SchemaCache, Maybe QueryTimings)
querySchemaCache conf@AppConfig{..} = do
SQL.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
Expand Down Expand Up @@ -180,11 +173,6 @@ querySchemaCache conf@AppConfig{..} = do
, dbRepresentations = reps
, dbMediaHandlers = HM.union mHdlers initialMediaHandlers -- the custom handlers will override the initial ones
, dbTimezones = tzones

, dbTablesFuzzyIndex =
-- Only build fuzzy index for schemas with a reasonable number of tables
-- Fuzzy.FuzzySet is memory heavy we just don't use it for large schemas
Fuzzy.fromList <$> HM.filter ((< maxDbTablesForFuzzySearch) . length) (HM.fromListWith (<>) ((qiSchema &&& pure . qiName) <$> HM.keys tabsWViewsPks))
}, qsTime)
where
schemas = toList configDbSchemas
Expand Down Expand Up @@ -223,7 +211,6 @@ removeInternal schemas dbStruct =
, dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API
, dbMediaHandlers = dbMediaHandlers dbStruct
, dbTimezones = dbTimezones dbStruct
, dbTablesFuzzyIndex = dbTablesFuzzyIndex dbStruct
}
where
hasInternalJunction ComputedRelationship{} = False
Expand Down
2 changes: 1 addition & 1 deletion test/io/postgrest.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class PostgrestProcess:
def read_stdout(self, nlines=1):
"Wait for line(s) on standard output."
output = []
for _ in range(10):
for _ in range(15):
self.process.stdout.flush()
line = self.process.stdout.readline()
if line:
Expand Down
24 changes: 2 additions & 22 deletions test/io/test_big_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,26 +97,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 == 404
assert response.status_code == 400
data = response.json()
assert data["code"] == "PGRST205"


def test_second_request_for_non_existent_table_should_be_quick(defaultenv):
"requesting a non-existent relationship should be quick after the fuzzy search index is loaded (2nd request)"

env = {
**defaultenv,
"PGRST_DB_SCHEMAS": "fuzzysearch",
"PGRST_DB_POOL": "2",
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous",
}

with run(env=env, wait_max_seconds=30) as postgrest:
response = postgrest.session.get("/unknown-table")
assert response.status_code == 404
data = response.json()
assert data["code"] == "PGRST205"
first_duration = response.elapsed.total_seconds()
response = postgrest.session.get("/unknown-table")
assert response.elapsed.total_seconds() < first_duration / 2
assert data["code"] == "PGRST200"
8 changes: 4 additions & 4 deletions test/io/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ def test_log_level(level, defaultenv):
response = postgrest.session.get("/")
assert response.status_code == 200

output = postgrest.read_stdout(nlines=9)
output = postgrest.read_stdout(nlines=13)

if level == "crit":
assert len(output) == 0
Expand Down Expand Up @@ -765,7 +765,7 @@ def test_log_level(level, defaultenv):
r'- - postgrest_test_anonymous \[.+\] "GET / HTTP/1.1" 200 \d+ "" "python-requests/.+"',
],
)
assert len(output) == 9
assert len(output) == 13
assert any("Connection" and "is available" in line for line in output)
assert any("Connection" and "is used" in line for line in output)

Expand Down Expand Up @@ -1118,10 +1118,10 @@ def test_stale_schema_cache_dropped_table_returns_database_error(defaultenv):
response = postgrest.session.get("/stale_schema_cache_items")
payload = response.json()
assert response.status_code == 404
assert payload["code"] == "PGRST205"
assert payload["code"] == "42P01"
assert (
payload["message"]
== "Could not find the table 'public.stale_schema_cache_items' in the schema cache"
== 'relation "public.stale_schema_cache_items" does not exist'
)
finally:
psql_as_superuser("drop table if exists stale_schema_cache_items;")
Expand Down
2 changes: 1 addition & 1 deletion test/spec/Feature/ConcurrentSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ spec withConfig = withConfig baseCfg $
raceTest 10 $
get "/fakefake"
`shouldRespondWith`
[json| {"code":"PGRST205","details":null,"hint":null,"message":"Could not find the table 'test.fakefake' in the schema cache"} |]
[json| {"code":"42P01","details":null,"hint":null,"message":"relation \"test.fakefake\" does not exist"} |]
{ matchStatus = 404
, matchHeaders = []
}
Expand Down
2 changes: 1 addition & 1 deletion test/spec/Feature/Query/DeleteSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ spec withConfig = withConfig baseCfg $
it "fails with 404" $
request methodDelete "/foozle?id=eq.101" [] ""
`shouldRespondWith`
[json| {"code":"PGRST205","details":null,"hint":null,"message":"Could not find the table 'test.foozle' in the schema cache"} |]
[json| {"code":"42P01","details":null,"hint":null,"message":"relation \"test.foozle\" does not exist"} |]
{ matchStatus = 404
, matchHeaders = []
}
Expand Down
Loading
Loading