Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a34d37bb82 | ||
|
|
5b45113565 | ||
|
|
733a896113 |
@@ -5,6 +5,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## [13.0.4] - 2025-06-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix regression that makes full-text search not work on domain types based on `tsvector` by @laurenceisla in #4135
|
||||||
|
- Fix `jwt-aud` config not failing when set to an invalid URI by @taimoorzaeem in #4132
|
||||||
|
|
||||||
## [13.0.3] - 2025-06-16
|
## [13.0.3] - 2025-06-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
name: postgrest
|
name: postgrest
|
||||||
version: 13.0.3
|
version: 13.0.4
|
||||||
synopsis: REST API for any Postgres database
|
synopsis: REST API for any Postgres database
|
||||||
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||||
for tables, views, and functions, supporting all HTTP methods that security
|
for tables, views, and functions, supporting all HTTP methods that security
|
||||||
|
|||||||
+16
-2
@@ -48,7 +48,7 @@ import Data.List.NonEmpty (fromList, toList)
|
|||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.Scientific (floatingOrInteger)
|
import Data.Scientific (floatingOrInteger)
|
||||||
import Jose.Jwk (Jwk, JwkSet)
|
import Jose.Jwk (Jwk, JwkSet)
|
||||||
import Network.URI (escapeURIString,
|
import Network.URI (escapeURIString, isURI,
|
||||||
isUnescapedInURIComponent)
|
isUnescapedInURIComponent)
|
||||||
import Numeric (readOct, showOct)
|
import Numeric (readOct, showOct)
|
||||||
import System.Environment (getEnvironment)
|
import System.Environment (getEnvironment)
|
||||||
@@ -281,7 +281,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
|
|||||||
<*> (fromMaybe "postgresql://" <$> optString "db-uri")
|
<*> (fromMaybe "postgresql://" <$> optString "db-uri")
|
||||||
<*> pure optPath
|
<*> pure optPath
|
||||||
<*> pure Nothing
|
<*> pure Nothing
|
||||||
<*> optString "jwt-aud"
|
<*> optStringOrURI "jwt-aud"
|
||||||
<*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key"
|
<*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key"
|
||||||
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
|
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
|
||||||
<*> (fromMaybe False <$> optWithAlias
|
<*> (fromMaybe False <$> optWithAlias
|
||||||
@@ -407,6 +407,20 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
|
|||||||
optStringEmptyable :: C.Key -> C.Parser C.Config (Maybe Text)
|
optStringEmptyable :: C.Key -> C.Parser C.Config (Maybe Text)
|
||||||
optStringEmptyable k = overrideFromDbOrEnvironment C.optional k coerceText
|
optStringEmptyable k = overrideFromDbOrEnvironment C.optional k coerceText
|
||||||
|
|
||||||
|
optStringOrURI :: C.Key -> C.Parser C.Config (Maybe Text)
|
||||||
|
optStringOrURI k = do
|
||||||
|
stringOrURI <- mfilter (/= "") <$> overrideFromDbOrEnvironment C.optional k coerceText
|
||||||
|
-- If the string contains ':' then it should
|
||||||
|
-- be a valid URI according to RFC 3986
|
||||||
|
case stringOrURI of
|
||||||
|
Just s -> if T.isInfixOf ":" s then validateURI s else return (Just s)
|
||||||
|
Nothing -> return Nothing
|
||||||
|
where
|
||||||
|
validateURI :: Text -> C.Parser C.Config (Maybe Text)
|
||||||
|
validateURI s = if isURI (T.unpack s)
|
||||||
|
then return $ Just s
|
||||||
|
else fail "jwt-aud should be a string or a valid URI"
|
||||||
|
|
||||||
optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
|
optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
|
||||||
optInt k = join <$> overrideFromDbOrEnvironment C.optional k coerceInt
|
optInt k = join <$> overrideFromDbOrEnvironment C.optional k coerceInt
|
||||||
|
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ data ResolverContext = ResolverContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolveColumnField :: Column -> Maybe ToTsVector -> CoercibleField
|
resolveColumnField :: Column -> Maybe ToTsVector -> CoercibleField
|
||||||
resolveColumnField col toTsV = CoercibleField (colName col) mempty False toTsV (colNominalType col) Nothing (colDefault col) False
|
resolveColumnField col toTsV = CoercibleField (colName col) mempty False toTsV (colNominalType col) (colType col) Nothing (colDefault col) False
|
||||||
|
|
||||||
resolveTableFieldName :: Table -> FieldName -> Maybe ToTsVector -> CoercibleField
|
resolveTableFieldName :: Table -> FieldName -> Maybe ToTsVector -> CoercibleField
|
||||||
resolveTableFieldName table fieldName toTsV=
|
resolveTableFieldName table fieldName toTsV=
|
||||||
@@ -291,12 +291,12 @@ resolveTypeOrUnknown ResolverContext{..} (fn, jp) toTsV =
|
|||||||
case res of
|
case res of
|
||||||
-- types that are already json/jsonb don't need to be converted with `to_jsonb` for using arrow operators `data->attr`
|
-- types that are already json/jsonb don't need to be converted with `to_jsonb` for using arrow operators `data->attr`
|
||||||
-- this prevents indexes not applying https://github.com/PostgREST/postgrest/issues/2594
|
-- this prevents indexes not applying https://github.com/PostgREST/postgrest/issues/2594
|
||||||
cf@CoercibleField{cfIRType="json"} -> cf{cfJsonPath=jp, cfToJson=False}
|
cf@CoercibleField{cfIRType="json"} -> cf{cfJsonPath=jp, cfToJson=False}
|
||||||
cf@CoercibleField{cfIRType="jsonb"} -> cf{cfJsonPath=jp, cfToJson=False}
|
cf@CoercibleField{cfIRType="jsonb"} -> cf{cfJsonPath=jp, cfToJson=False}
|
||||||
-- Do not apply to_tsvector to tsvector types
|
-- Do not apply to_tsvector to tsvector types
|
||||||
cf@CoercibleField{cfIRType="tsvector"} -> cf{cfJsonPath=jp, cfToJson=True, cfToTsVector=Nothing}
|
cf@CoercibleField{cfBaseType="tsvector"} -> cf{cfJsonPath=jp, cfToJson=True, cfToTsVector=Nothing}
|
||||||
-- other types will get converted `to_jsonb(col)->attr`, even unknown types
|
-- other types will get converted `to_jsonb(col)->attr`, even unknown types
|
||||||
cf -> cf{cfJsonPath=jp, cfToJson=True}
|
cf -> cf{cfJsonPath=jp, cfToJson=True}
|
||||||
where
|
where
|
||||||
res = fromMaybe (unknownField fn jp) $ HM.lookup qi tables >>=
|
res = fromMaybe (unknownField fn jp) $ HM.lookup qi tables >>=
|
||||||
Just . (\t -> resolveTableFieldName t fn toTsV)
|
Just . (\t -> resolveTableFieldName t fn toTsV)
|
||||||
@@ -891,7 +891,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
|
|||||||
-- where_ = [
|
-- where_ = [
|
||||||
-- CoercibleStmnt (
|
-- CoercibleStmnt (
|
||||||
-- CoercibleFilter {
|
-- CoercibleFilter {
|
||||||
-- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False},
|
-- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False},
|
||||||
-- opExpr = op
|
-- opExpr = op
|
||||||
-- }
|
-- }
|
||||||
-- )
|
-- )
|
||||||
@@ -907,7 +907,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
|
|||||||
-- Don't do anything to the filter if there's no embedding (a subtree) on projects. Assume it's a normal filter.
|
-- Don't do anything to the filter if there's no embedding (a subtree) on projects. Assume it's a normal filter.
|
||||||
--
|
--
|
||||||
-- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nullOp [])
|
-- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nullOp [])
|
||||||
-- Right [CoercibleStmnt (CoercibleFilter {field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson = False, cfToTsVector = Nothing, cfIRType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False}, opExpr = OpExpr True (Is IsNull)})]
|
-- Right [CoercibleStmnt (CoercibleFilter {field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson = False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False}, opExpr = OpExpr True (Is IsNull)})]
|
||||||
--
|
--
|
||||||
-- If there's an embedding on projects, then change the filter to use the internal aggregate name (`clients_projects_1`) so the filter can succeed later.
|
-- If there's an embedding on projects, then change the filter to use the internal aggregate name (`clients_projects_1`) so the filter can succeed later.
|
||||||
--
|
--
|
||||||
@@ -926,7 +926,7 @@ addNullEmbedFilters (Node rp@ReadPlan{where_=curLogic} forest) = do
|
|||||||
newNullFilters rPlans = \case
|
newNullFilters rPlans = \case
|
||||||
(CoercibleExpr b lOp trees) ->
|
(CoercibleExpr b lOp trees) ->
|
||||||
CoercibleExpr b lOp <$> (newNullFilters rPlans `traverse` trees)
|
CoercibleExpr b lOp <$> (newNullFilters rPlans `traverse` trees)
|
||||||
flt@(CoercibleStmnt (CoercibleFilter (CoercibleField fld [] _ _ _ _ _ _) opExpr)) ->
|
flt@(CoercibleStmnt (CoercibleFilter CoercibleField{cfName=fld, cfJsonPath=[]} opExpr)) ->
|
||||||
let foundRP = find (\ReadPlan{relName, relAlias} -> fld == fromMaybe relName relAlias) rPlans in
|
let foundRP = find (\ReadPlan{relName, relAlias} -> fld == fromMaybe relName relAlias) rPlans in
|
||||||
case (foundRP, opExpr) of
|
case (foundRP, opExpr) of
|
||||||
(Just ReadPlan{relAggAlias}, OpExpr b (Is IsNull)) -> Right $ CoercibleStmnt $ CoercibleFilterNullEmbed b relAggAlias
|
(Just ReadPlan{relAggAlias}, OpExpr b (Is IsNull)) -> Right $ CoercibleStmnt $ CoercibleFilterNullEmbed b relAggAlias
|
||||||
|
|||||||
@@ -44,13 +44,14 @@ data CoercibleField = CoercibleField
|
|||||||
, cfToJson :: Bool
|
, cfToJson :: Bool
|
||||||
, cfToTsVector :: Maybe ToTsVector -- ^ If the field should be converted using to_tsvector(<language>, <field>)
|
, cfToTsVector :: Maybe ToTsVector -- ^ If the field should be converted using to_tsvector(<language>, <field>)
|
||||||
, cfIRType :: Text -- ^ The native Postgres type of the field, the intermediate (IR) type before mapping.
|
, cfIRType :: Text -- ^ The native Postgres type of the field, the intermediate (IR) type before mapping.
|
||||||
|
, cfBaseType :: Text -- ^ The base type of the field in case of domains, or just the type otherwise (without modifiers in case of pg_catalog types)
|
||||||
, cfTransform :: Maybe TransformerProc -- ^ The optional mapping from irType -> targetType.
|
, cfTransform :: Maybe TransformerProc -- ^ The optional mapping from irType -> targetType.
|
||||||
, cfDefault :: Maybe Text
|
, cfDefault :: Maybe Text
|
||||||
, cfFullRow :: Bool -- ^ True if the field represents the whole selected row. Used in spread rels: instead of COUNT(*), it does a COUNT(<row>) in order to not mix with other spreaded resources.
|
, cfFullRow :: Bool -- ^ True if the field represents the whole selected row. Used in spread rels: instead of COUNT(*), it does a COUNT(<row>) in order to not mix with other spreaded resources.
|
||||||
} deriving (Eq, Show)
|
} deriving (Eq, Show)
|
||||||
|
|
||||||
unknownField :: FieldName -> JsonPath -> CoercibleField
|
unknownField :: FieldName -> JsonPath -> CoercibleField
|
||||||
unknownField name path = CoercibleField name path False Nothing "" Nothing Nothing False
|
unknownField name path = CoercibleField name path False Nothing "" "" Nothing Nothing False
|
||||||
|
|
||||||
-- | Like an API request LogicTree, but with coercible field information.
|
-- | Like an API request LogicTree, but with coercible field information.
|
||||||
data CoercibleLogicTree
|
data CoercibleLogicTree
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ callPlanToQuery (FunctionCall qi params arguments returnsScalar returnsSetOfScal
|
|||||||
KeyParams [] -> "FROM " <> callIt mempty
|
KeyParams [] -> "FROM " <> callIt mempty
|
||||||
KeyParams prms -> case arguments of
|
KeyParams prms -> case arguments of
|
||||||
DirectArgs args -> "FROM " <> callIt (fmtArgs prms args)
|
DirectArgs args -> "FROM " <> callIt (fmtArgs prms args)
|
||||||
JsonArgs json -> fromJsonBodyF json ((\p -> CoercibleField (ppName p) mempty False Nothing (ppTypeMaxLength p) Nothing Nothing False) <$> prms) False True False <> ", " <>
|
JsonArgs json -> fromJsonBodyF json ((\p -> CoercibleField (ppName p) mempty False Nothing (ppTypeMaxLength p) mempty Nothing Nothing False) <$> prms) False True False <> ", " <>
|
||||||
"LATERAL " <> callIt (fmtParams prms)
|
"LATERAL " <> callIt (fmtParams prms)
|
||||||
|
|
||||||
callIt :: SQL.Snippet -> SQL.Snippet
|
callIt :: SQL.Snippet -> SQL.Snippet
|
||||||
|
|||||||
@@ -41,12 +41,11 @@ cli:
|
|||||||
use_defaultenv: true
|
use_defaultenv: true
|
||||||
env:
|
env:
|
||||||
PGRST_SERVER_UNIX_SOCKET_MODE: '778'
|
PGRST_SERVER_UNIX_SOCKET_MODE: '778'
|
||||||
# TODO: Bug needs to be fixed
|
- name: invalid jwt-aud
|
||||||
# - name: invalid jwt-aud
|
expect: error
|
||||||
# expect: error
|
use_defaultenv: true
|
||||||
# use_defaultenv: true
|
env:
|
||||||
# env:
|
PGRST_JWT_AUD: 'http://%%localhorst.invalid'
|
||||||
# PGRST_JWT_AUD: 'htp:/@@localhorst.invalid'
|
|
||||||
- name: invalid log-level
|
- name: invalid log-level
|
||||||
expect: error
|
expect: error
|
||||||
use_defaultenv: true
|
use_defaultenv: true
|
||||||
|
|||||||
@@ -278,3 +278,15 @@ def test_schema_cache_snapshot(baseenv, key, snapshot_yaml):
|
|||||||
Dumper=yaml.SafeDumper if key == "dbTimezones" else ExtraNewLinesDumper,
|
Dumper=yaml.SafeDumper if key == "dbTimezones" else ExtraNewLinesDumper,
|
||||||
)
|
)
|
||||||
assert formatted == snapshot_yaml
|
assert formatted == snapshot_yaml
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_aud_config_set_to_invalid_uri(defaultenv):
|
||||||
|
"PostgREST should exit with an error message in output if jwt-aud config is set to an invalid URI"
|
||||||
|
env = {
|
||||||
|
**defaultenv,
|
||||||
|
"PGRST_JWT_AUD": "foo://%%$$^^.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(PostgrestError):
|
||||||
|
dump = cli(["--dump-config"], env=env).split("\n")
|
||||||
|
assert "jwt-aud should be a string or a valid URI" in dump
|
||||||
|
|||||||
@@ -294,6 +294,20 @@ spec = do
|
|||||||
]|]
|
]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works when the column type is a tsvector domain" $ do
|
||||||
|
get "tsearch_to_tsvector?select=text_search_domain&text_search_domain=fts(simple).of" `shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"text_search_domain":"'do':7 'fun':5 'impossible':9 'it':1 'kind':3 'of':4 's':2 'the':8 'to':6"}
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "works when the column type is a recursive tsvector domain" $ do
|
||||||
|
get "tsearch_to_tsvector?select=text_search_rec_domain&text_search_rec_domain=fts(simple).of" `shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"text_search_rec_domain":"'do':7 'fun':5 'impossible':9 'it':1 'kind':3 'of':4 's':2 'the':8 'to':6"}
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "text and json columns" $ do
|
context "text and json columns" $ do
|
||||||
it "finds matches with to_tsquery" $ do
|
it "finds matches with to_tsquery" $ do
|
||||||
get "/tsearch_to_tsvector?select=text_search&text_search=fts.impossible" `shouldRespondWith`
|
get "/tsearch_to_tsvector?select=text_search&text_search=fts.impossible" `shouldRespondWith`
|
||||||
|
|||||||
@@ -998,6 +998,22 @@ spec =
|
|||||||
|]
|
|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "should work with filters that use the fts operator when the column type is a tsvector domain" $
|
||||||
|
get "/rpc/get_tsearch_to_tsvector?select=text_search_domain&text_search_domain=fts(simple).impossible" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"text_search_domain":"'do':7 'fun':5 'impossible':9 'it':1 'kind':3 'of':4 's':2 'the':8 'to':6"},
|
||||||
|
{"text_search_domain":"'amusant':5 'c':1 'de':6 'est':2 'faire':7 'impossible':9 'l':8 'peu':4 'un':3"}]
|
||||||
|
|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "should work with filters that use the fts operator when the column type is a recursive tsvector domain" $
|
||||||
|
get "/rpc/get_tsearch_to_tsvector?select=text_search_rec_domain&text_search_rec_domain=fts(simple).impossible" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"text_search_rec_domain":"'do':7 'fun':5 'impossible':9 'it':1 'kind':3 'of':4 's':2 'the':8 'to':6"},
|
||||||
|
{"text_search_rec_domain":"'amusant':5 'c':1 'de':6 'est':2 'faire':7 'impossible':9 'l':8 'peu':4 'un':3"}]
|
||||||
|
|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "should work with the phraseto_tsquery function" $
|
it "should work with the phraseto_tsquery function" $
|
||||||
get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith`
|
get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith`
|
||||||
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
||||||
|
|||||||
Vendored
+2
-1
@@ -954,7 +954,8 @@ INSERT INTO tsearch_to_tsvector(text_search) VALUES ('C''est un peu amusant de f
|
|||||||
INSERT INTO tsearch_to_tsvector(text_search) VALUES ('Es ist eine Art Spaß, das Unmögliche zu machen');
|
INSERT INTO tsearch_to_tsvector(text_search) VALUES ('Es ist eine Art Spaß, das Unmögliche zu machen');
|
||||||
|
|
||||||
UPDATE tsearch_to_tsvector SET jsonb_search = jsonb_build_object('text_search', text_search);
|
UPDATE tsearch_to_tsvector SET jsonb_search = jsonb_build_object('text_search', text_search);
|
||||||
|
UPDATE tsearch_to_tsvector SET text_search_domain = to_tsvector('simple', text_search);
|
||||||
|
UPDATE tsearch_to_tsvector SET text_search_rec_domain = to_tsvector('simple', text_search);
|
||||||
|
|
||||||
TRUNCATE TABLE artists CASCADE;
|
TRUNCATE TABLE artists CASCADE;
|
||||||
INSERT INTO artists
|
INSERT INTO artists
|
||||||
|
|||||||
Vendored
+9
-1
@@ -3750,9 +3750,17 @@ create table surr_gen_default_upsert (
|
|||||||
extra text
|
extra text
|
||||||
);
|
);
|
||||||
|
|
||||||
|
create domain tsvector_not_null as tsvector
|
||||||
|
constraint "tsvector is required" check (value is not null);
|
||||||
|
|
||||||
|
create domain tsvector_not_empty as tsvector_not_null
|
||||||
|
constraint "tsvector is required and not empty" check (value <> '');
|
||||||
|
|
||||||
create table tsearch_to_tsvector (
|
create table tsearch_to_tsvector (
|
||||||
text_search text,
|
text_search text,
|
||||||
jsonb_search jsonb
|
jsonb_search jsonb,
|
||||||
|
text_search_domain tsvector_not_null default '',
|
||||||
|
text_search_rec_domain tsvector_not_empty default '.'
|
||||||
);
|
);
|
||||||
|
|
||||||
create function test.get_tsearch_to_tsvector() returns setof test.tsearch_to_tsvector AS $$
|
create function test.get_tsearch_to_tsvector() returns setof test.tsearch_to_tsvector AS $$
|
||||||
|
|||||||
Reference in New Issue
Block a user