From 490d1dc5d3f6fa41b5d3dd1d6d2b061566cb7543 Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Fri, 8 May 2026 20:42:57 -0500 Subject: [PATCH] add: config to emit warning for legacy target names Adds the `url_use_legacy_target_names` config. Enabled (default): * It allows using the resource name in filters, orders or limits when it has an alias, e.g. `table?select=alias:target(*)&target.id=eq.1` * Logs a WARNING with a hint to use the alias * Returns a Warning header in the response Disabled: * It returns an error, only the alias is allowed * No warnings returned This feature is deprecated --- CHANGELOG.md | 10 +- docs/references/configuration.rst | 34 +++++++ src/PostgREST/App.hs | 28 +++++- src/PostgREST/Config.hs | 8 ++ src/PostgREST/Config/Database.hs | 1 + src/PostgREST/Error.hs | 6 +- src/PostgREST/Error/Types.hs | 2 +- src/PostgREST/Logger.hs | 4 + src/PostgREST/Observation.hs | 1 + src/PostgREST/Plan.hs | 99 +++++++++++++------ src/PostgREST/Plan/ReadPlan.hs | 34 +++---- ...chema_cache_snapshot[dbRelationships].yaml | 68 +++++++++++++ .../test_schema_cache_snapshot[dbTables].yaml | 58 +++++++++++ test/io/configs/expected/aliases.config | 1 + .../configs/expected/boolean-numeric.config | 1 + .../io/configs/expected/boolean-string.config | 1 + test/io/configs/expected/defaults.config | 1 + ...efaults-with-db-other-authenticator.config | 1 + .../expected/no-defaults-with-db.config | 1 + test/io/configs/expected/no-defaults.config | 1 + test/io/configs/expected/types.config | 1 + test/io/configs/expected/utf-8.config | 1 + test/io/configs/no-defaults-env.yaml | 1 + test/io/configs/no-defaults.config | 1 + test/io/fixtures/db_config.sql | 2 + test/io/fixtures/privileges.sql | 2 +- test/io/fixtures/schema.sql | 8 ++ test/io/test_io.py | 30 ++++++ test/load/fixtures.sql | 1 - test/observability/ObsHelper.hs | 1 + test/spec/Feature/Query/QuerySpec.hs | 44 +++++++-- test/spec/Main.hs | 1 + test/spec/SpecHelper.hs | 1 + 33 files changed, 385 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea4eace5c..0dafa059f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file. From versio - Shutdown should wait for in flight requests by @mkleczek in #4702 - Remove automatic transaction retries on `40001 (serialization_failure)` errors to prevent replication lag by @laurenceisla in #3673 - Fix unexpected results when embedding and filtering the same table more than once by @laurenceisla in #4075 + + You need to set `url-use-legacy-target-names = false`. - If the schema cache fails to reload, PostgREST will no longer stop serving requests and will continue doing so in a "best effort" basis by @mkleczek in #4873 #4869 - Stop reporting 503s errors unnecessarily while the schema cache is loading at startup by @mkleczek in #4880 - Fix responding with `Something went wrong` on Admin server when under EMFILE by @mkleczek in #5077 @@ -34,8 +35,6 @@ All notable changes to this project will be documented in this file. From versio + Now fails at startup. Prior to this, it failed with `PGRST205` on requests related to these schemas. - Build a static executable for aarch64-linux by @wolfgangwalther in #4193 - Build the minimal docker image for aarch64-linux by @wolfgangwalther in #4193 -- The name of an embedded table can no longer be used in filters if it has an alias by @laurenceisla in #4075 - + e.g. `?select=alias:table(*)&table.id=eq.1` is not possible anymore, use `?select=alias:table(*)&alias.id=eq.1` instead. - Config `jwt-role-claim-key` now uses RFC 9535 syntax for JSON Path by @taimoorzaeem in #4984 #### Changed Syntax for JWT Role Extraction @@ -50,6 +49,13 @@ The `jwt-role-claim-key` config should be updated according to the following: + Example: `.roles[?(@ ^== "postgrest_test_")]` -> `$.roles[?search(@, "^postgrest_test_")]` - Detailed reference for syntax: [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html#name-jsonpath-syntax-and-semanti). +### Deprecated + +- Deprecate filters, orders and limits with the name of an embedded table when it has an alias by @steve-chavez, @laurenceisla in #4075 + + e.g. `?select=alias:table(*)&table.id=eq.1` will not be possible anymore, use `?select=alias:table(*)&alias.id=eq.1` instead. + + You will see a warning in the logs when this happens. + + You can disable this behavior now by setting `url-use-legacy-target-names = false`. + ## [14.14] - 2026-06-29 ### Fixed diff --git a/docs/references/configuration.rst b/docs/references/configuration.rst index 3f7c97831..78192562f 100644 --- a/docs/references/configuration.rst +++ b/docs/references/configuration.rst @@ -1013,3 +1013,37 @@ server-unix-socket-mode .. code:: bash server-unix-socket-mode = "660" + +.. _url-use-legacy-target-names: + +url-use-legacy-target-names +--------------------------- + + =============== ================================= + **Type** Boolean + **Default** True + **Reloadable** Y + **Environment** PGRST_URL_USE_LEGACY_TARGET_NAMES + **In-Database** pgrst.url_use_legacy_target_names + =============== ================================= + + When active, it allows using the the name of an embedded table in filters, orders or limits even if it has an alias: + + .. code:: bash + + curl "http://localhost:3000/table?select=alias:target(*)&target.order=id" -i + + .. code:: text + + Warning: 299 PostgRESTv16 "Embedded resource was referenced by relation name even though it has an alias. This is deprecated and will stop working in a future release. Update `target` to `alias` in query string filters, orders or limits." + [...] + + Note that the response includes a deprecation message in the ``Warning`` header. + This will also show in the PostgREST logs: + + .. code:: + + 28/May/2026:20:33:22 -0500: WARNING: Embedded resource was referenced by relation name even though it has an alias. This is deprecated and will stop working in a future release. + 28/May/2026:20:33:22 -0500: Update filters, orders or limits that use `target` to `alias` in `GET /table?select=alias:target(*)&target.order=id` + + This feature will be removed in a future release, so you should start using the ``alias`` in these cases. diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 0f592ee99..b598f4482 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -71,7 +71,7 @@ import qualified Data.List as L import Data.Streaming.Network (bindPortTCP) import qualified Data.Text as T import qualified Network.HTTP.Types as HTTP -import Network.HTTP.Types.Header (hVary) +import Network.HTTP.Types.Header (hVary, hWarning) import qualified Network.Socket as NS import PostgREST.Unix (createAndBindDomainSocket) import System.Posix.Types (FileMode) @@ -210,6 +210,14 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul (parseTime, apiReq@ApiRequest{..}) <- withTiming conf $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req body (planTime, plan) <- withTiming conf $ liftEither $ Plan.actionPlan iAction conf apiReq sCache + let warnings = Plan.legacyWarnings plan + legacyWarnMsg = "Embedded resource was referenced by relation name even though it has an alias. This is deprecated and will stop working in a future release." + legacyWarnHint = let replacement (relName, alias) = "`" <> relName <> "` to `" <> alias <> "`" in T.intercalate ", " (replacement <$> warnings) + shouldShowWarnings = configUrlUseLegacyTargetNames && not (null warnings) + + liftIO $ when shouldShowWarnings $ + observer $ LegacyTargetNameWarningObs (legacyWarnMsg, legacyWarnHint) iMethod (iPath <> Wai.rawQueryString req) -- TODO maybe store rawQueryString in ApiRequest for consistency + let mainQ = Query.mainQuery plan conf apiReq authResult configDbPreRequest tx = MainTx.mainTx mainQ conf authResult apiReq plan sCache obsQuery s = when configLogQuery $ observer $ QueryObs mainQ s @@ -235,12 +243,14 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul liftIO $ obsQuery status' liftEither response - return $ toWaiResponse (ServerTiming jwtTime parseTime planTime txTime respTime) resp + let warnHdrMsgs = if shouldShowWarnings then Just (legacyWarnMsg, legacyWarnHint) else Nothing + + return $ toWaiResponse (ServerTiming jwtTime parseTime planTime txTime respTime) warnHdrMsgs resp where - toWaiResponse :: ServerTiming -> Response.PgrstResponse -> Wai.Response - toWaiResponse timing (Response.PgrstResponse st hdrs bod) = - Wai.responseLBS st (hdrs ++ serverTimingHeaders timing ++ [varyHeader | not $ varyHeaderPresent hdrs]) bod + toWaiResponse :: ServerTiming -> Maybe (Text, Text) -> Response.PgrstResponse -> Wai.Response + toWaiResponse timing warnMsgs (Response.PgrstResponse st hdrs bod) = + Wai.responseLBS st (hdrs ++ serverTimingHeaders timing ++ warningHeaders warnMsgs ++ [varyHeader | not $ varyHeaderPresent hdrs]) bod serverTimingHeaders :: ServerTiming -> [HTTP.Header] serverTimingHeaders timing = [serverTimingHeader timing | configServerTimingEnabled] @@ -251,6 +261,14 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul varyHeaderPresent :: [HTTP.Header] -> Bool varyHeaderPresent = any (\(h, _v) -> h == hVary) + warningHeaders :: Maybe (Text, Text) -> [HTTP.Header] + warningHeaders Nothing = [] + warningHeaders (Just (msg, hint)) = + let warnMsg = msg <> " Update " <> hint <> " in query string filters, orders or limits." + pgrstVer = "PostgRESTv" <> BS.filter (/= ' ') prettyVersion + in + [(hWarning, "299 " <> pgrstVer <> " \"" <> encodeUtf8 warnMsg <> "\"")] + withTiming :: (MonadError e m, MonadIO m) => AppConfig -> m a -> m (Maybe Double, a) withTiming AppConfig{configServerTimingEnabled} f = if configServerTimingEnabled then do diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 914128ab8..c9828e75b 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -121,6 +121,7 @@ data AppConfig = AppConfig , configServerTimingEnabled :: Bool , configServerUnixSocket :: Maybe FilePath , configServerUnixSocketMode :: FileMode + , configUrlUseLegacyTargetNames :: Bool , configAdminServerHost :: Text , configAdminServerPort :: Maybe Int , configAdminServerUnixSocket :: Maybe FilePath @@ -207,6 +208,7 @@ toText conf = ,("server-timing-enabled", T.toLower . show . configServerTimingEnabled) ,("server-unix-socket", q . maybe mempty T.pack . configServerUnixSocket) ,("server-unix-socket-mode", q . T.pack . showSocketMode) + ,("url-use-legacy-target-names", T.toLower . show . configUrlUseLegacyTargetNames) ,("admin-server-host", q . configAdminServerHost) ,("admin-server-port", maybe "\"\"" show . configAdminServerPort) ,("admin-server-unix-socket", q . maybe mempty T.pack . configAdminServerUnixSocket) @@ -325,6 +327,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl = <*> (fromMaybe False <$> optBool "server-timing-enabled") <*> (fmap T.unpack <$> optString "server-unix-socket") <*> parseSocketFileMode "server-unix-socket-mode" + <*> (fromMaybe True <$> optBool "url-use-legacy-target-names") <*> (defaultServerHost <$> optWithAlias (optString "admin-server-host") (optString "server-host")) <*> parseAdminServerPort "admin-server-port" @@ -794,4 +797,9 @@ exampleConfigFile = S.unlines , "## Unix socket file mode" , "## When none is provided, 660 is applied by default" , "# server-unix-socket-mode = \"660\"" + , "" + , "## Use legacy target names in relationship filters" + , "## If active, allows using the target name of the relationship in filters even if it has an alias." + , "## Otherwise it only allows the alias in filters" + , "url-use-legacy-target-names = true" ] diff --git a/src/PostgREST/Config/Database.hs b/src/PostgREST/Config/Database.hs index 9c9755490..cd567bffd 100644 --- a/src/PostgREST/Config/Database.hs +++ b/src/PostgREST/Config/Database.hs @@ -71,6 +71,7 @@ dbSettingsNames = ,"server_cors_allowed_origins" ,"server_trace_header" ,"server_timing_enabled" + ,"url_use_legacy_target_names" ] queryPgVersion :: Session PgVersion diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index b6e1d0cc0..39df7982d 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -175,7 +175,7 @@ instance ErrorBody ApiRequestError where message InvalidFilters = "Filters must include all and only primary key columns with 'eq' operators" message (UnacceptableSchema sch _) = "Invalid schema: " <> sch message (MediaTypeError cts) = "None of these media types are available: " <> T.intercalate ", " (map T.decodeUtf8 cts) - message (NotEmbedded resource) = "'" <> resource <> "' is not an embedded resource in this request" + message (NotEmbedded resource _) = "'" <> resource <> "' is not an embedded resource in this request" message GucHeadersError = "response.headers guc must be a JSON array composed of objects with a single key and a string value" message GucStatusError = "response.status guc must be a valid status code" message PutLimitNotAllowedError = "limit/offset querystring parameters are not allowed for PUT" @@ -207,11 +207,13 @@ instance ErrorBody ApiRequestError where details (InvalidPreferences prefs) = Just $ JSON.String $ T.decodeUtf8 ("Invalid preferences: " <> BS.intercalate ", " prefs) details (MaxAffectedViolationError n) = Just $ JSON.String $ T.unwords ["The query affects", show n, "rows"] details (NotImplemented details') = Just $ JSON.String details' + details (NotEmbedded _ (Just _)) = Just $ JSON.String "Target names are not allowed in filters if they have an alias" details _ = Nothing -- HINT: Maybe JSON.Value - hint (NotEmbedded resource) = Just $ JSON.String $ "Verify that '" <> resource <> "' is included in the 'select' query parameter." + hint (NotEmbedded resource Nothing) = Just $ JSON.String $ "Verify that '" <> resource <> "' is included in the 'select' query parameter." + hint (NotEmbedded _ (Just (name, alias))) = Just $ JSON.String $ "Change '" <> name <> "' to '" <> alias <> "' in filters, orders or limits." hint (PGRSTParseError raiseErr) = Just $ JSON.String $ pgrstParseErrorHint raiseErr hint (UnacceptableSchema _ schemas) = Just $ JSON.String $ "Only the following schemas are exposed: " <> T.intercalate ", " schemas diff --git a/src/PostgREST/Error/Types.hs b/src/PostgREST/Error/Types.hs index eb2f13dbc..6129590e6 100644 --- a/src/PostgREST/Error/Types.hs +++ b/src/PostgREST/Error/Types.hs @@ -44,7 +44,7 @@ data ApiRequestError | InvalidPreferences [ByteString] | InvalidRange RangeError | InvalidRpcMethod ByteString - | NotEmbedded Text + | NotEmbedded Text (Maybe (Text, Text)) | NotImplemented Text | PutLimitNotAllowedError | QueryParamError QPError diff --git a/src/PostgREST/Logger.hs b/src/PostgREST/Logger.hs index 8e6d7d7fd..3d3af8176 100644 --- a/src/PostgREST/Logger.hs +++ b/src/PostgREST/Logger.hs @@ -192,6 +192,10 @@ observationMessages = \case let snipts = renderSnippet <$> [mqTxVars, fromMaybe mempty mqPreReq, mqMain, x, y, z, fromMaybe mempty mqExplain] in showOnSingleLine '\n' . T.decodeUtf8 <$> filter (/= mempty) snipts + LegacyTargetNameWarningObs (warningMsg, warningHints) requestMethod requestTarget -> + [ "WARNING: " <> warningMsg + , "Update filters, orders or limits that use " <> warningHints <> " in " <> "`" <> T.decodeUtf8 (requestMethod <> " " <> requestTarget) <> "`" + ] ConfigReadErrorObs usageErr -> pure $ "Failed to query database settings for the config parameters." <> jsonMessage usageErr QueryRoleSettingsErrorObs usageErr -> diff --git a/src/PostgREST/Observation.hs b/src/PostgREST/Observation.hs index 125d1b268..a9778aad4 100644 --- a/src/PostgREST/Observation.hs +++ b/src/PostgREST/Observation.hs @@ -45,6 +45,7 @@ data Observation | DBListenerGotConfigMsg ByteString | DBListenerConnectionCleanupFail SomeException | QueryObs MainQuery Status + | LegacyTargetNameWarningObs (Text, Text) ByteString ByteString | ConfigReadErrorObs SQL.UsageError | ConfigInvalidObs Text | ConfigSucceededObs diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index bb7a0d4a1..7fd157c19 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -23,6 +23,7 @@ module PostgREST.Plan , InspectPlan(..) , InfoPlan(..) , CrudPlan(..) + , legacyWarnings ) where import qualified Data.HashMap.Strict as HM @@ -144,6 +145,25 @@ data InfoPlan | RoutineInfoPlan Routine -- info about function | SchemaInfoPlan -- info about schema cache +legacyWarnings :: ActionPlan -> [(Text, Text)] +legacyWarnings (NoDb _) = [] +legacyWarnings (Db dbPlan) = + case dbPlan of + DbCrud _ crudPlan -> + readPlanWarnings $ case crudPlan of + WrappedReadPlan{wrReadPlan} -> wrReadPlan + MutateReadPlan{mrReadPlan} -> mrReadPlan + CallReadPlan{crReadPlan} -> crReadPlan + MayUseDb _ -> [] + where + readPlanWarnings :: ReadPlanTree -> [(Text, Text)] + readPlanWarnings (Node rp forest) = + maybeToList (readPlanWarning rp) <> foldMap readPlanWarnings forest + +readPlanWarning :: ReadPlan -> Maybe (Text, Text) +readPlanWarning ReadPlan{relName, relAlias = Just alias, relIsLegacyTargetNameMatch = True} = Just (relName, alias) +readPlanWarning _ = Nothing + actionPlan :: Action -> AppConfig -> ApiRequest -> SchemaCache -> Either Error ActionPlan actionPlan act conf apiReq sCache = case act of ActDb dbAct -> Db <$> dbActionPlan dbAct conf apiReq sCache @@ -354,7 +374,7 @@ resolveQueryInputField ctx field opExpr = withTextParse ctx $ resolveTypeOrUnkno -- | 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 qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregates, configUrlUseLegacyTargetNames} 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" @@ -369,10 +389,10 @@ readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregate addAliases =<< expandStars ctx =<< addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<< - addLogicTrees ctx apiRequest =<< - addRanges apiRequest =<< - addOrders ctx apiRequest =<< - addFilters ctx apiRequest (initReadRequest ctx $ QueryParams.qsSelect $ iQueryParams apiRequest) + addLogicTrees ctx apiRequest configUrlUseLegacyTargetNames =<< + addRanges apiRequest configUrlUseLegacyTargetNames =<< + addOrders ctx apiRequest configUrlUseLegacyTargetNames =<< + addFilters ctx apiRequest configUrlUseLegacyTargetNames (initReadRequest ctx $ QueryParams.qsSelect $ iQueryParams apiRequest) -- Build the initial read plan tree initReadRequest :: ResolverContext -> [Tree SelectItem] -> ReadPlanTree @@ -380,7 +400,7 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} = foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} [] where rootDepth = 0 - defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing Nothing [] rootDepth + defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing Nothing [] rootDepth False treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree treeEntry depth (Node si fldForest) (Node q rForest) = let nxtDepth = succ depth in @@ -816,8 +836,8 @@ findTable qi@QualifiedIdentifier{..} sc@SchemaCache{dbTables} = Nothing -> Left $ SchemaCacheErr $ TableNotFound qiSchema qiName sc Just _ -> Right qi -addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree -addFilters ctx ApiRequest{..} rReq = +addFilters :: ResolverContext -> ApiRequest -> Bool -> ReadPlanTree -> Either Error ReadPlanTree +addFilters ctx ApiRequest{..} useTargetNames rReq = foldr addFilterToNode (Right rReq) flts where QueryParams.QueryParams{..} = iQueryParams @@ -829,15 +849,15 @@ addFilters ctx ApiRequest{..} rReq = addFilterToNode :: (EmbedPath, Filter) -> Either Error ReadPlanTree -> Either Error ReadPlanTree addFilterToNode = - updateNode (\flt (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=addFilterToLogicForest (resolveFilter ctx{qi=fromTable} flt) lf} f) + updateNode useTargetNames (\flt (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=addFilterToLogicForest (resolveFilter ctx{qi=fromTable} flt) lf} f) -addOrders :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree -addOrders ctx ApiRequest{..} rReq = foldr addOrderToNode (Right rReq) qsOrder +addOrders :: ResolverContext -> ApiRequest -> Bool -> ReadPlanTree -> Either Error ReadPlanTree +addOrders ctx ApiRequest{..} useTargetNames rReq = foldr addOrderToNode (Right rReq) qsOrder where QueryParams.QueryParams{..} = iQueryParams addOrderToNode :: (EmbedPath, [OrderTerm]) -> Either Error ReadPlanTree -> Either Error ReadPlanTree - addOrderToNode = updateNode (\o (Node q f) -> Node q{order=resolveOrder ctx <$> o} f) + addOrderToNode = updateNode useTargetNames (\o (Node q f) -> Node q{order=resolveOrder ctx <$> o} f) resolveOrder :: ResolverContext -> OrderTerm -> CoercibleOrderTerm resolveOrder _ (OrderRelationTerm a b c d) = CoercibleOrderRelationTerm a b c d @@ -862,7 +882,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do then Right $ cot{coRelation=relAggAlias} else Left $ ApiRequestErr $ RelatedOrderNotToOne (qiName from) name Nothing -> - Left $ ApiRequestErr $ NotEmbedded coRelation + Left $ ApiRequestErr $ NotEmbedded coRelation Nothing -- | Searches for null filters on embeds, e.g. `projects=not.is.null` on `GET /clients?select=*,projects(*)&projects=not.is.null` -- @@ -887,7 +907,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do -- relToParent = Nothing, -- relJoinConds = [], -- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relSpread = Nothing, depth = 1, --- relSelect = [] +-- relSelect = [], +-- relIsLegacyTargetNameMatch = False -- }, -- subForest = [] -- } @@ -913,7 +934,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do -- ], -- order = [], range_ = fullRange, relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing, -- relJoinType = Nothing, relSpread = Nothing, depth = 0, --- relSelect = [] +-- relSelect = [], +-- relIsLegacyTargetNameMatch = False -- }, -- subForest = subForst -- } @@ -949,8 +971,8 @@ addNullEmbedFilters (Node rp@ReadPlan{where_=curLogic} forest) = do flt@(CoercibleStmnt _) -> Right flt -addRanges :: ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree -addRanges ApiRequest{..} rReq = +addRanges :: ApiRequest -> Bool -> ReadPlanTree -> Either Error ReadPlanTree +addRanges ApiRequest{..} useTargetNames rReq = case iAction of ActDb (ActRelationMut _ _) -> Right rReq _ -> foldr addRangeToNode (Right rReq) =<< ranges @@ -959,10 +981,10 @@ addRanges ApiRequest{..} rReq = ranges = first (ApiRequestErr . QueryParamError) $ QueryParams.pRequestRange `traverse` HM.toList iRange addRangeToNode :: (EmbedPath, NonnegRange) -> Either Error ReadPlanTree -> Either Error ReadPlanTree - addRangeToNode = updateNode (\r (Node q f) -> Node q{range_=r} f) + addRangeToNode = updateNode useTargetNames (\r (Node q f) -> Node q{range_=r} f) -addLogicTrees :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree -addLogicTrees ctx ApiRequest{..} rReq = +addLogicTrees :: ResolverContext -> ApiRequest -> Bool -> ReadPlanTree -> Either Error ReadPlanTree +addLogicTrees ctx ApiRequest{..} useTargetNames rReq = foldr addLogicTreeToNode (Right rReq) logic where QueryParams.QueryParams{..} = iQueryParams @@ -975,7 +997,7 @@ addLogicTrees ctx ApiRequest{..} rReq = _ -> filter (not . null . fst) qsLogic addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either Error ReadPlanTree -> Either Error ReadPlanTree - addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f) + addLogicTreeToNode = updateNode useTargetNames (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f) resolveLogicTree :: ResolverContext -> LogicTree -> CoercibleLogicTree resolveLogicTree ctx (Stmnt flt) = CoercibleStmnt $ resolveFilter ctx flt @@ -985,18 +1007,35 @@ resolveFilter :: ResolverContext -> Filter -> CoercibleFilter resolveFilter ctx (Filter fld opExpr) = CoercibleFilter{field=resolveQueryInputField ctx fld opExpr, opExpr=opExpr} -- Find a Node of the Tree and apply a function to it -updateNode :: (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either Error ReadPlanTree -> Either Error ReadPlanTree -updateNode f ([], a) rr = f a <$> rr -updateNode _ _ (Left e) = Left e -updateNode f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) = - case findNode of - Nothing -> Left $ ApiRequestErr $ NotEmbedded targetNodeName +updateNode :: Bool -> (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either Error ReadPlanTree -> Either Error ReadPlanTree +updateNode _ f ([], a) rr = f a <$> rr +updateNode _ _ _ (Left e) = Left e +updateNode useTargetNames f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) = + case findNode useTargetNames of + Nothing -> + Left $ ApiRequestErr $ NotEmbedded targetNodeName findLegacyUsage Just target -> (\node -> Node rootNode $ node : delete target forest) <$> - updateNode f (remainingPath, a) (Right target) + updateNode useTargetNames f (remainingPath, a) (Right $ updateLegacyAttrs target) where - findNode :: Maybe ReadPlanTree - findNode = find (\(Node ReadPlan{relName, relAlias} _) -> fromMaybe relName relAlias == targetNodeName) forest + findNode :: Bool -> Maybe ReadPlanTree + findNode isLegacy = find (matchTarget isLegacy) forest + + matchTarget :: Bool -> ReadPlanTree -> Bool + matchTarget isLegacy (Node ReadPlan{relName, relAlias} _) + | isLegacy = relName == targetNodeName || relAlias == Just targetNodeName + | otherwise = fromMaybe relName relAlias == targetNodeName + + updateLegacyAttrs :: ReadPlanTree -> ReadPlanTree + updateLegacyAttrs node@(Node rPlan@ReadPlan{relName, relAlias} children) + | relName == targetNodeName && isJust relAlias && relAlias /= Just targetNodeName = + Node rPlan{relIsLegacyTargetNameMatch=True} children + | otherwise = node + + findLegacyUsage :: Maybe (Text, Text) + findLegacyUsage + | useTargetNames = Nothing + | otherwise = (\(Node rp _) -> fromMaybe mempty $ readPlanWarning rp) . updateLegacyAttrs <$> findNode True mutatePlan :: Mutation -> QualifiedIdentifier -> ApiRequest -> SchemaCache -> ReadPlanTree -> Either Error MutatePlan mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{dbTables, dbRepresentations} readReq = diff --git a/src/PostgREST/Plan/ReadPlan.hs b/src/PostgREST/Plan/ReadPlan.hs index 22bd7d62e..1c30d36d4 100644 --- a/src/PostgREST/Plan/ReadPlan.hs +++ b/src/PostgREST/Plan/ReadPlan.hs @@ -32,22 +32,22 @@ data JoinCondition = -- TODO: Enforce uniqueness of columns by changing to a Set instead of a List where applicable data ReadPlan = ReadPlan - { select :: [CoercibleSelectField] - , from :: QualifiedIdentifier - , fromAlias :: Maybe Alias - , where_ :: [CoercibleLogicTree] - , order :: [CoercibleOrderTerm] - , range_ :: NonnegRange - , relName :: NodeName - , relToParent :: Maybe Relationship - , relJoinConds :: [JoinCondition] - , relAlias :: Maybe Alias - , relAggAlias :: Alias - , relHint :: Maybe Hint - , relJoinType :: Maybe JoinType - , relSpread :: Maybe SpreadType - , relSelect :: [RelSelectField] - , depth :: Depth - -- ^ used for aliasing + { select :: [CoercibleSelectField] + , from :: QualifiedIdentifier + , fromAlias :: Maybe Alias + , where_ :: [CoercibleLogicTree] + , order :: [CoercibleOrderTerm] + , range_ :: NonnegRange + , relName :: NodeName + , relToParent :: Maybe Relationship + , relJoinConds :: [JoinCondition] + , relAlias :: Maybe Alias + , relAggAlias :: Alias + , relHint :: Maybe Hint + , relJoinType :: Maybe JoinType + , relSpread :: Maybe SpreadType + , relSelect :: [RelSelectField] + , depth :: Depth -- ^ used for aliasing + , relIsLegacyTargetNameMatch :: Bool -- ^ used to ease migration into a new version with breaking change } deriving (Eq, Show) diff --git a/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbRelationships].yaml b/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbRelationships].yaml index c633fc35b..d97823c0c 100644 --- a/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbRelationships].yaml +++ b/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbRelationships].yaml @@ -1,7 +1,59 @@ +- - - qiName: awards + qiSchema: public + - public + - - relCardinality: + relColumns: + - - director_id + - id + relCons: awards_director_id_fkey + tag: M2O + relFTableIsView: false + relForeignTable: + qiName: directors + qiSchema: public + relIsSelf: false + relTable: + qiName: awards + qiSchema: public + relTableIsView: false + tag: Relationship + - relCardinality: + relColumns: + - - film_id + - id + relCons: awards_film_id_fkey + tag: M2O + relFTableIsView: false + relForeignTable: + qiName: films + qiSchema: public + relIsSelf: false + relTable: + qiName: awards + qiSchema: public + relTableIsView: false + tag: Relationship + - - - qiName: directors qiSchema: public - public - - relCardinality: + relColumns: + - - id + - director_id + relCons: awards_director_id_fkey + tag: O2M + relFTableIsView: false + relForeignTable: + qiName: awards + qiSchema: public + relIsSelf: false + relTable: + qiName: directors + qiSchema: public + relTableIsView: false + tag: Relationship + - relCardinality: relColumns: - - id - director_id @@ -22,6 +74,22 @@ qiSchema: public - public - - relCardinality: + relColumns: + - - id + - film_id + relCons: awards_film_id_fkey + tag: O2M + relFTableIsView: false + relForeignTable: + qiName: awards + qiSchema: public + relIsSelf: false + relTable: + qiName: films + qiSchema: public + relTableIsView: false + tag: Relationship + - relCardinality: relColumns: - - director_id - id diff --git a/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbTables].yaml b/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbTables].yaml index 1f1ac761e..dca22f9e0 100644 --- a/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbTables].yaml +++ b/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbTables].yaml @@ -126,6 +126,64 @@ tableSchema: public tableUpdatable: false +- - qiName: awards + qiSchema: public + - tableColumns: + director_id: + colDefault: null + colDescription: null + colEnum: [] + colMaxLen: null + colName: director_id + colNominalType: integer + colNullable: true + colType: integer + film_id: + colDefault: null + colDescription: null + colEnum: [] + colMaxLen: null + colName: film_id + colNominalType: integer + colNullable: true + colType: integer + id: + colDefault: null + colDescription: null + colEnum: [] + colMaxLen: null + colName: id + colNominalType: integer + colNullable: false + colType: integer + name: + colDefault: null + colDescription: null + colEnum: [] + colMaxLen: null + colName: name + colNominalType: text + colNullable: true + colType: text + year: + colDefault: null + colDescription: null + colEnum: [] + colMaxLen: null + colName: year + colNominalType: integer + colNullable: true + colType: integer + tableDeletable: true + tableDescription: null + tableInsertable: true + tableIsView: false + tableName: awards + tablePKCols: + - id + tableSchema: public + tableUpdatable: true + - - qiName: films qiSchema: public - tableColumns: diff --git a/test/io/configs/expected/aliases.config b/test/io/configs/expected/aliases.config index ae27520ff..58f644e5a 100644 --- a/test/io/configs/expected/aliases.config +++ b/test/io/configs/expected/aliases.config @@ -42,3 +42,4 @@ server-timing-enabled = false server-trace-header = "" server-unix-socket = "" server-unix-socket-mode = "660" +url-use-legacy-target-names = true diff --git a/test/io/configs/expected/boolean-numeric.config b/test/io/configs/expected/boolean-numeric.config index 2f0332720..5398f64c1 100644 --- a/test/io/configs/expected/boolean-numeric.config +++ b/test/io/configs/expected/boolean-numeric.config @@ -42,3 +42,4 @@ server-timing-enabled = false server-trace-header = "" server-unix-socket = "" server-unix-socket-mode = "660" +url-use-legacy-target-names = true diff --git a/test/io/configs/expected/boolean-string.config b/test/io/configs/expected/boolean-string.config index 2f0332720..5398f64c1 100644 --- a/test/io/configs/expected/boolean-string.config +++ b/test/io/configs/expected/boolean-string.config @@ -42,3 +42,4 @@ server-timing-enabled = false server-trace-header = "" server-unix-socket = "" server-unix-socket-mode = "660" +url-use-legacy-target-names = true diff --git a/test/io/configs/expected/defaults.config b/test/io/configs/expected/defaults.config index da3a95ef7..dd1e8c571 100644 --- a/test/io/configs/expected/defaults.config +++ b/test/io/configs/expected/defaults.config @@ -42,3 +42,4 @@ server-timing-enabled = false server-trace-header = "" server-unix-socket = "" server-unix-socket-mode = "660" +url-use-legacy-target-names = true diff --git a/test/io/configs/expected/no-defaults-with-db-other-authenticator.config b/test/io/configs/expected/no-defaults-with-db-other-authenticator.config index a1727d662..32bda8014 100644 --- a/test/io/configs/expected/no-defaults-with-db-other-authenticator.config +++ b/test/io/configs/expected/no-defaults-with-db-other-authenticator.config @@ -44,3 +44,4 @@ server-timing-enabled = true server-trace-header = "traceparent" server-unix-socket = "/tmp/pgrst_io_test.sock" server-unix-socket-mode = "777" +url-use-legacy-target-names = true diff --git a/test/io/configs/expected/no-defaults-with-db.config b/test/io/configs/expected/no-defaults-with-db.config index 45c99b4e1..b8826b59c 100644 --- a/test/io/configs/expected/no-defaults-with-db.config +++ b/test/io/configs/expected/no-defaults-with-db.config @@ -44,3 +44,4 @@ server-timing-enabled = false server-trace-header = "CF-Ray" server-unix-socket = "/tmp/pgrst_io_test.sock" server-unix-socket-mode = "777" +url-use-legacy-target-names = true diff --git a/test/io/configs/expected/no-defaults.config b/test/io/configs/expected/no-defaults.config index a75fb5d52..8615dc4f3 100644 --- a/test/io/configs/expected/no-defaults.config +++ b/test/io/configs/expected/no-defaults.config @@ -44,3 +44,4 @@ server-timing-enabled = true server-trace-header = "X-Request-Id" server-unix-socket = "/tmp/pgrst_io_test.sock" server-unix-socket-mode = "777" +url-use-legacy-target-names = false diff --git a/test/io/configs/expected/types.config b/test/io/configs/expected/types.config index 5ce9cde22..52c322a39 100644 --- a/test/io/configs/expected/types.config +++ b/test/io/configs/expected/types.config @@ -43,3 +43,4 @@ server-timing-enabled = false server-trace-header = "" server-unix-socket = "" server-unix-socket-mode = "660" +url-use-legacy-target-names = true diff --git a/test/io/configs/expected/utf-8.config b/test/io/configs/expected/utf-8.config index 99c4b65f4..68d710ba3 100644 --- a/test/io/configs/expected/utf-8.config +++ b/test/io/configs/expected/utf-8.config @@ -42,3 +42,4 @@ server-timing-enabled = false server-trace-header = "" server-unix-socket = "" server-unix-socket-mode = "660" +url-use-legacy-target-names = true diff --git a/test/io/configs/no-defaults-env.yaml b/test/io/configs/no-defaults-env.yaml index 3f64cf464..71aea2e9e 100644 --- a/test/io/configs/no-defaults-env.yaml +++ b/test/io/configs/no-defaults-env.yaml @@ -41,6 +41,7 @@ PGRST_SERVER_TRACE_HEADER: X-Request-Id PGRST_SERVER_TIMING_ENABLED: true PGRST_SERVER_UNIX_SOCKET: /tmp/pgrst_io_test.sock PGRST_SERVER_UNIX_SOCKET_MODE: 777 +PGRST_URL_USE_LEGACY_TARGET_NAMES: false PGRST_ADMIN_SERVER_HOST: 127.0.0.1 PGRST_ADMIN_SERVER_PORT: 3001 PGRST_ADMIN_SERVER_UNIX_SOCKET: /tmp/admin_io_test.sock diff --git a/test/io/configs/no-defaults.config b/test/io/configs/no-defaults.config index 96e1ad814..93b8c8d1d 100644 --- a/test/io/configs/no-defaults.config +++ b/test/io/configs/no-defaults.config @@ -38,6 +38,7 @@ server-trace-header = "X-Request-Id" server-timing-enabled = true server-unix-socket = "/tmp/pgrst_io_test.sock" server-unix-socket-mode = "777" +url-use-legacy-target-names = false admin-server-port = 3001 admin-server-host = "127.0.0.1" admin-server-unix-socket = "/tmp/admin_io_test.sock" diff --git a/test/io/fixtures/db_config.sql b/test/io/fixtures/db_config.sql index 1a17280ee..d171b4533 100644 --- a/test/io/fixtures/db_config.sql +++ b/test/io/fixtures/db_config.sql @@ -25,6 +25,7 @@ ALTER ROLE db_config_authenticator SET pgrst.openapi_server_proxy_uri = 'https:/ ALTER ROLE db_config_authenticator SET pgrst.server_cors_allowed_origins = 'http://origin.com'; ALTER ROLE db_config_authenticator SET pgrst.server_timing_enabled = 'false'; ALTER ROLE db_config_authenticator SET pgrst.server_trace_header = 'CF-Ray'; +ALTER ROLE db_config_authenticator SET pgrst.url_use_legacy_target_names = 'true'; ALTER ROLE db_config_authenticator SET pgrst.db_hoisted_tx_settings = 'autovacuum_work_mem'; -- override with database specific setting @@ -81,6 +82,7 @@ ALTER ROLE other_authenticator SET pgrst.openapi_server_proxy_uri = 'https://oth ALTER ROLE other_authenticator SET pgrst.server_cors_allowed_origins = 'http://otherorigin.com'; ALTER ROLE other_authenticator SET pgrst.server_timing_enabled = 'true'; ALTER ROLE other_authenticator SET pgrst.server_trace_header = 'traceparent'; +ALTER ROLE other_authenticator SET pgrst.url_use_legacy_target_names = 'true'; ALTER ROLE other_authenticator SET pgrst.db_hoisted_tx_settings = 'maintenance_work_mem'; create schema postgrest; diff --git a/test/io/fixtures/privileges.sql b/test/io/fixtures/privileges.sql index 62fbe55df..56398b774 100644 --- a/test/io/fixtures/privileges.sql +++ b/test/io/fixtures/privileges.sql @@ -3,7 +3,7 @@ GRANT USAGE ON SCHEMA test TO postgrest_test_anonymous; GRANT SELECT ON authors_only TO postgrest_test_author; GRANT SELECT ON projects TO postgrest_test_anonymous, postgrest_test_w_superuser_settings; -GRANT SELECT ON directors, films TO postgrest_test_anonymous, postgrest_test_w_superuser_settings; +GRANT SELECT ON directors, films, awards TO postgrest_test_anonymous, postgrest_test_w_superuser_settings; GRANT ALL ON cats TO postgrest_test_anonymous; GRANT ALL ON items_w_isolation_level TO postgrest_test_anonymous, postgrest_test_repeatable_read, postgrest_test_serializable; diff --git a/test/io/fixtures/schema.sql b/test/io/fixtures/schema.sql index 039ec0c0c..46800bc9b 100644 --- a/test/io/fixtures/schema.sql +++ b/test/io/fixtures/schema.sql @@ -25,6 +25,14 @@ CREATE TABLE films ( on delete cascade ); +CREATE TABLE awards ( + id int primary key, + name text, + year int, + film_id int references films(id), + director_id int references directors(id) +); + -- data to test resource embedding TRUNCATE TABLE directors CASCADE; INSERT INTO directors diff --git a/test/io/test_io.py b/test/io/test_io.py index 64be0e5aa..4cf7d93a2 100644 --- a/test/io/test_io.py +++ b/test/io/test_io.py @@ -2089,3 +2089,33 @@ def test_work_mem_in_role_settings(defaultenv): response = postgrest.session.post("/rpc/get_work_mem", headers=headers) assert response.status_code == 200 assert response.text == '"3MB"' + + +@pytest.mark.parametrize("enabled", ["true", "false"]) +def test_use_legacy_target_names(enabled, defaultenv): + "Show a warning when a target name is used instead of an alias, only when config is enabled" + + env = { + **defaultenv, + "PGRST_URL_USE_LEGACY_TARGET_NAMES": enabled, + } + + with run(env=env) as postgrest: + response = postgrest.session.get( + "/directors?select=name,all_films:films(title),awards_2026:awards(name)&films.order=title&awards.year=eq.2026" + ) + + output = postgrest.read_stdout(nlines=10) + + log_err_warning = "WARNING: Embedded resource was referenced by relation name even though it has an alias. This is deprecated and will stop working in a future release." + log_err_hint = "Update filters, orders or limits that use `films` to `all_films`, `awards` to `awards_2026` in `GET /directors?select=name,all_films:films(title),awards_2026:awards(name)&films.order=title&awards.year=eq.2026`" + + has_warning_log = any(log_err_warning in line for line in output) + has_hint_log = any(log_err_hint in line for line in output) + + if enabled == "true": + assert response.status_code == 200 + assert has_warning_log and has_hint_log + else: + assert response.status_code == 400 + assert not has_warning_log and not has_hint_log diff --git a/test/load/fixtures.sql b/test/load/fixtures.sql index 8f844c483..ecedcdb99 100644 --- a/test/load/fixtures.sql +++ b/test/load/fixtures.sql @@ -33,7 +33,6 @@ CREATE TABLE test.roles ( character TEXT ); - CREATE TABLE test.authors_only (); CREATE FUNCTION test.call_me (name TEXT) RETURNS TEXT diff --git a/test/observability/ObsHelper.hs b/test/observability/ObsHelper.hs index ecfecfa86..5c68a0a89 100644 --- a/test/observability/ObsHelper.hs +++ b/test/observability/ObsHelper.hs @@ -122,6 +122,7 @@ baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in , configInternalSCQuerySleepFst = Nothing , configInternalSCQuerySleepSnd = Nothing , configServerTimingEnabled = True + , configUrlUseLegacyTargetNames = True } testCfg :: AppConfig diff --git a/test/spec/Feature/Query/QuerySpec.hs b/test/spec/Feature/Query/QuerySpec.hs index 52805d865..4282e9162 100644 --- a/test/spec/Feature/Query/QuerySpec.hs +++ b/test/spec/Feature/Query/QuerySpec.hs @@ -7,6 +7,7 @@ import Test.Hspec hiding (pendingWith) import Test.Hspec.Wai import Test.Hspec.Wai.JSON +import PostgREST.Config (AppConfig (..)) import PostgREST.Config.PgVersion (PgVersion, pgVersion190) import Protolude hiding (get) @@ -1071,16 +1072,6 @@ spec actualPgVersion withConfig = withConfig baseCfg $ do { "id":4,"children":[]} ]|] { matchHeaders = [matchContentTypeJson] } - it "works when embedding the same table more than once" $ - get "/places?select=name,visits(id,start_time,visit_type),work_visits:visits(id,start_time,visit_type)&id=eq.1&visits.visit_type=neq.work&visits.start_time=gt.20250101+00:00&work_visits.visit_type=eq.work&work_visits.start_time=gt.20250101+00:00" `shouldRespondWith` - [json|[ - { - "name":"Lake", - "visits":[{"id": 1, "start_time": "2025-01-01T10:00:00", "visit_type": "vacation"}, {"id": 2, "start_time": "2025-01-01T15:00:00", "visit_type": "vacation"}], - "work_visits":[{"id": 3, "start_time": "2025-01-01T20:00:00", "visit_type": "work"}] - } - ]|] { matchHeaders = [matchContentTypeJson] } - describe "ordering response" $ do it "by a column asc" $ get "/items?id=lte.2&order=id.asc" @@ -1186,6 +1177,12 @@ spec actualPgVersion withConfig = withConfig baseCfg $ do ] } + it "filters, orders and limits aliased embeded entities using the target name" $ + get "/projects?id=eq.1&select=id, name, the_tasks:tasks(id, name)&tasks.name=like.Code*&tasks.order=name.asc&tasks.limit=1" `shouldRespondWith` + [json|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"}]}]|] + { matchHeaders = ["Warning" <:> "299 PostgRESTv15(pre-release) \"Embedded resource was referenced by relation name even though it has an alias. This is deprecated and will stop working in a future release. Update `tasks` to `the_tasks` in query string filters, orders or limits.\""] } + + describe "Accept headers" $ do it "should respond an unknown accept type with 406" $ request methodGet "/simple_pk" @@ -1690,3 +1687,30 @@ spec actualPgVersion withConfig = withConfig baseCfg $ do [json| {"code":"PGRST125","details":null,"hint":null,"message":"Invalid path specified in request URL"} |] { matchStatus = 404 , matchHeaders = ["Content-Length" <:> "96"]} + +specLegacyTargetNames :: SpecWithConfig +specLegacyTargetNames withConfig = withConfig (baseCfg { configUrlUseLegacyTargetNames = False }) $ + context "disable legacy target names" $ do + describe "Shaping response with select parameter" $ do + it "works when embedding the same table more than once" $ + get "/places?select=name,visits(id,start_time,visit_type),work_visits:visits(id,start_time,visit_type)&id=eq.1&visits.visit_type=neq.work&visits.start_time=gt.20250101+00:00&work_visits.visit_type=eq.work&work_visits.start_time=gt.20250101+00:00" `shouldRespondWith` + [json|[ + { + "name":"Lake", + "visits":[{"id": 1, "start_time": "2025-01-01T10:00:00", "visit_type": "vacation"}, {"id": 2, "start_time": "2025-01-01T15:00:00", "visit_type": "vacation"}], + "work_visits":[{"id": 3, "start_time": "2025-01-01T20:00:00", "visit_type": "work"}] + } + ]|] { matchHeaders = [matchContentTypeJson] } + describe "ordering response" $ + it "filters, orders or limits do not work with aliased embeded entities using the target name" $ + get "/projects?id=eq.1&select=id, name, the_tasks:tasks(id, name)&tasks.name=like.Code*&tasks.order=name.asc&tasks.limit=1" `shouldRespondWith` + [json| + { + "code":"PGRST108", + "details":"Target names are not allowed in filters if they have an alias", + "hint":"Change 'tasks' to 'the_tasks' in filters, orders or limits.", + "message":"'tasks' is not an embedded resource in this request" + } + |] + { matchStatus = 400, + matchHeaders = [matchContentTypeJson] } diff --git a/test/spec/Main.hs b/test/spec/Main.hs index 58056fef6..975804dd5 100644 --- a/test/spec/Main.hs +++ b/test/spec/Main.hs @@ -152,6 +152,7 @@ main = do , ("Feature.Query.PreparedStatementsSpec.spec" , Feature.Query.PreparedStatementsSpec.spec) , ("Feature.Query.QueryLimitedSpec" , Feature.Query.QueryLimitedSpec.spec) , ("Feature.Query.QuerySpec" , Feature.Query.QuerySpec.spec actualPgVersion) + , ("Feature.Query.QuerySpec.specLegacyTargetNames" , Feature.Query.QuerySpec.specLegacyTargetNames) , ("Feature.Query.RangeSpec" , Feature.Query.RangeSpec.spec) , ("Feature.Query.RawOutputTypesSpec" , Feature.Query.RawOutputTypesSpec.spec) , ("Feature.Query.RelatedQueriesSpec" , Feature.Query.RelatedQueriesSpec.spec) diff --git a/test/spec/SpecHelper.hs b/test/spec/SpecHelper.hs index 1d239c1b6..42439b333 100644 --- a/test/spec/SpecHelper.hs +++ b/test/spec/SpecHelper.hs @@ -175,6 +175,7 @@ baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in , configServerTraceHeader = Nothing , configServerUnixSocket = Nothing , configServerUnixSocketMode = 432 + , configUrlUseLegacyTargetNames = True , configDbTxAllowOverride = True , configDbTxRollbackAll = True , configAdminServerHost = "localhost"