feat: filter top-level resource with embed filter

This is enabled by adding `!inner` to the embedded resource

/projects?select=*,clients!inner(*)&clients.id=eq.12

This behaviour can be enabled by default with the config option

db-embed-default-join='inner'

Which saves the need for specifying `!inner` on every request.
If this is enabled, the previous behavior can be restored
per request by specifying `!left`  on the embedded resource.

/projects?select=*,clients!left(*)&clients.id=eq.12`

Tested on M20/02M/M2M relationships, views, RPC.
This commit is contained in:
steve-chavez
2021-10-04 13:46:32 -05:00
committed by Steve Chavez
parent bf91187e63
commit ee56dd5db1
27 changed files with 445 additions and 44 deletions
+260
View File
@@ -0,0 +1,260 @@
module Feature.EmbedInnerJoinSpec where
import Network.Wai (Application)
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec =
describe "Embedding with an inner join" $ do
context "many-to-one relationships" $ do
it "ignores null embeddings while the default left join doesn't" $ do
get "/projects?select=id,clients!inner(id)" `shouldRespondWith`
[json|[
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/projects?select=id,clients!left(id)" `shouldRespondWith`
[json|[
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},
{"id":5,"clients":null}]|]
{ matchHeaders = [matchContentTypeJson] }
it "filters source tables when the embedded table is filtered" $ do
get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
[json|[
{"id":1,"clients":{"id":1}},
{"id":2,"clients":{"id":1}}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/projects?select=id,clients!inner(id)&clients.id=eq.2" `shouldRespondWith`
[json|[
{"id":3,"clients":{"id":2}},
{"id":4,"clients":{"id":2}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith`
[json|[]|]
{ matchHeaders = [matchContentTypeJson] }
it "filters source tables when a two levels below embedded table is filtered" $ do
get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" `shouldRespondWith`
[json|[
{"id":1,"projects":{"id":1,"clients":{"id":1}}},
{"id":2,"projects":{"id":1,"clients":{"id":1}}},
{"id":3,"projects":{"id":2,"clients":{"id":1}}},
{"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
[json|[
{"id":5,"projects":{"id":3,"clients":{"id":2}}},
{"id":6,"projects":{"id":3,"clients":{"id":2}}},
{"id":7,"projects":{"id":4,"clients":{"id":2}}},
{"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "only affects the source table rows if his direct embedding is an inner join" $
get "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
[json|[
{"id":1,"projects":null},
{"id":2,"projects":null},
{"id":3,"projects":null},
{"id":4,"projects":null},
{"id":5,"projects":{"id":3,"clients":{"id":2}}},
{"id":6,"projects":{"id":3,"clients":{"id":2}}},
{"id":7,"projects":{"id":4,"clients":{"id":2}}},
{"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with views" $
get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith`
[json| [{"title":"1984","authors":{"name":"George Orwell"}}] |]
{ matchHeaders = [matchContentTypeJson] }
context "one-to-many relationships" $ do
it "ignores empty array embeddings while the default left join doesn't" $ do
get "/entities?select=id,child_entities!inner(id)" `shouldRespondWith`
[json|[
{"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
{"id":2,"child_entities":[{"id":3}, {"id":6}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/entities?select=id,child_entities!left(id)" `shouldRespondWith`
[json| [
{"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
{"id":2,"child_entities":[{"id":3}, {"id":6}]},
{"id":3,"child_entities":[]},
{"id":4,"child_entities":[]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "filters source tables when the embedded table is filtered" $ do
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" `shouldRespondWith`
[json|[{"id":1,"child_entities":[{"id":1}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.3" `shouldRespondWith`
[json|[{"id":2,"child_entities":[{"id":3}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith`
[json|[]|]
{ matchHeaders = [matchContentTypeJson] }
it "filters source tables when a two levels below embedded table is filtered" $ do
get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)"
`shouldRespondWith`
[json|[
{
"id": 1,
"child_entities": [
{ "id": 1, "grandchild_entities": [ { "id": 1 } ] },
{ "id": 2, "grandchild_entities": [ { "id": 5 } ] }]
}
]|]
{ matchHeaders = [matchContentTypeJson] }
get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
[json|[
{
"id": 1,
"child_entities": [
{ "id": 1, "grandchild_entities": [ { "id": 2 } ] } ]
}
]|]
{ matchHeaders = [matchContentTypeJson] }
it "only affects the source table rows if his direct embedding is an inner join" $
get "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
[json|[
{
"id": 1,
"child_entities": [
{ "id": 1, "grandchild_entities": [ { "id": 2 } ] },
{ "id": 2, "grandchild_entities": [] },
{ "id": 4, "grandchild_entities": [] },
{ "id": 5, "grandchild_entities": [] } ]
},
{
"id": 2,
"child_entities": [
{ "id": 3, "grandchild_entities": [] },
{ "id": 6, "grandchild_entities": [] } ]
}
]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with views" $
get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith`
[json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1}]}] |]
{ matchHeaders = [matchContentTypeJson] }
context "many-to-many relationships" $ do
it "ignores empty array embeddings while the default left join doesn't" $ do
get "/products?select=id,suppliers!inner(id)" `shouldRespondWith`
[json| [
{"id":1,"suppliers":[{"id":1}, {"id":2}]},
{"id":2,"suppliers":[{"id":1}, {"id":3}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/products?select=id,suppliers!left(id)" `shouldRespondWith`
[json| [
{"id":1,"suppliers":[{"id":1}, {"id":2}]},
{"id":2,"suppliers":[{"id":1}, {"id":3}]},
{"id":3,"suppliers":[]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "filters source tables when the embedded table is filtered" $ do
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" `shouldRespondWith`
[json| [{"id":1,"suppliers":[{"id":2}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.3" `shouldRespondWith`
[json| [{"id":2,"suppliers":[{"id":3}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
it "filters source tables when a two levels below embedded table is filtered" $ do
get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3"
`shouldRespondWith`
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.4"
`shouldRespondWith`
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "only affects the source table rows if his direct embedding is an inner join" $
get "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" `shouldRespondWith`
[json|[
{"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]},
{"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with views" $ do
get "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" `shouldRespondWith`
[json| [{"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith`
[json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "works with m2o and m2m relationships combined" $
get "/projects?select=name,clients!inner(name),users!inner(name)" `shouldRespondWith`
[json| [
{"name":"Windows 7","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}, {"name":"Dwight Schrute"}]},
{"name":"Windows 10","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}]},
{"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]},
{"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|]
{ matchHeaders = [matchContentTypeJson] }
it "works with rpc" $
get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
[json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "works when using hints" $ do
get "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" `shouldRespondWith`
[json| [{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith`
[json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |]
{ matchHeaders = [matchContentTypeJson] }
notDefaultConfig :: SpecWith ((), Application)
notDefaultConfig =
describe "Embedding with a default inner join(db-embed-default-join = 'inner')" $ do
it "works on many-to-one relationships" $
get "/tasks?select=id,projects(id,clients(id))&projects.clients.id=eq.1" `shouldRespondWith`
[json|[
{"id":1,"projects":{"id":1,"clients":{"id":1}}},
{"id":2,"projects":{"id":1,"clients":{"id":1}}},
{"id":3,"projects":{"id":2,"clients":{"id":1}}},
{"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]
{ matchHeaders = [matchContentTypeJson] }
it "works on one-to-many relationships" $
get "/entities?select=id,child_entities(id,grandchild_entities(id))&child_entities.grandchild_entities.id=in.(1,5)"
`shouldRespondWith`
[json|[
{
"id": 1,
"child_entities": [
{ "id": 1, "grandchild_entities": [ { "id": 1 } ] },
{ "id": 2, "grandchild_entities": [ { "id": 5 } ] }]
}
]|]
{ matchHeaders = [matchContentTypeJson] }
it "works on many-to-many relationships" $
get "/products?select=id,suppliers(id,trade_unions(id))&suppliers.trade_unions.id=eq.3"
`shouldRespondWith`
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can restore default left join behavior" $
get "/projects?select=id,clients!left(id)" `shouldRespondWith`
[json|[
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},
{"id":5,"clients":null}]|]
{ matchHeaders = [matchContentTypeJson] }
+7
View File
@@ -30,6 +30,7 @@ import qualified Feature.CorsSpec
import qualified Feature.DeleteSpec
import qualified Feature.DisabledOpenApiSpec
import qualified Feature.EmbedDisambiguationSpec
import qualified Feature.EmbedInnerJoinSpec
import qualified Feature.ExtraSearchPathSpec
import qualified Feature.HtmlRawOutputSpec
import qualified Feature.IgnorePrivOpenApiSpec
@@ -94,6 +95,7 @@ main = do
let withApp = app testCfg
maxRowsApp = app testMaxRowsCfg
embedInnerJoinApp = app testEmbedInnerJoinCfg
disabledOpenApi = app testDisabledOpenApiCfg
proxyApp = app testProxyCfg
noJwtApp = app testCfgNoJWT
@@ -125,6 +127,7 @@ main = do
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
, ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)
, ("Feature.EmbedInnerJoinSpec" , Feature.EmbedInnerJoinSpec.spec)
, ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
, ("Feature.OpenApiSpec" , Feature.OpenApiSpec.spec actualPgVersion)
@@ -207,6 +210,10 @@ main = do
parallel $ before multipleSchemaApp $
describe "Feature.MultipleSchemaSpec" $ Feature.MultipleSchemaSpec.spec actualPgVersion
-- this test runs with db-embed-default-join = inner
before embedInnerJoinApp $
describe "Feature.EmbedInnerJoinSpecNotDefaultConfig" Feature.EmbedInnerJoinSpec.notDefaultConfig
-- Note: the rollback tests can not run in parallel, because they test persistance and
-- this results in race conditions
+5
View File
@@ -28,6 +28,7 @@ import PostgREST.Config (AppConfig (..),
OpenAPIMode (..),
parseSecret)
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
import PostgREST.Request.Types (JoinType (..))
import Protolude hiding (toS)
import Protolude.Conv (toS)
@@ -89,6 +90,7 @@ _baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in
, configDbSchemas = fromList ["test"]
, configDbConfig = False
, configDbUri = mempty
, configDbEmbedDefaultJoin = JTLeft
, configFilePath = Nothing
, configJWKS = parseSecret <$> secret
, configJwtAudience = Nothing
@@ -125,6 +127,9 @@ testUnicodeCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["
testMaxRowsCfg :: Text -> AppConfig
testMaxRowsCfg testDbConn = (testCfg testDbConn) { configDbMaxRows = Just 2 }
testEmbedInnerJoinCfg :: Text -> AppConfig
testEmbedInnerJoinCfg testDbConn = (testCfg testDbConn) { configDbEmbedDefaultJoin = JTInner }
testDisabledOpenApiCfg :: Text -> AppConfig
testDisabledOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OADisabled }
+16 -1
View File
@@ -691,4 +691,19 @@ DO $do$BEGIN
INSERT INTO test.reference_to_partitioned(id) VALUES (1);
INSERT INTO test.reference_to_partitioned(id, id_a, name_a) VALUES (2, 2, 'first');
END IF;
END$do$;
END$do$;
TRUNCATE TABLE test.products CASCADE;
INSERT INTO test.products (id, name) VALUES (1,'product-1'), (2,'product-2'), (3,'product-3');
TRUNCATE TABLE test.suppliers CASCADE;
INSERT INTO test.suppliers (id, name) VALUES (1,'supplier-1'), (2,'supplier-2'), (3, 'supplier-3');
TRUNCATE TABLE test.products_suppliers CASCADE;
INSERT INTO test.products_suppliers (product_id, supplier_id) VALUES (1,1), (1,2), (2,1), (2,3);
TRUNCATE TABLE test.trade_unions CASCADE;
INSERT INTO test.trade_unions (id, name) VALUES (1,'union-1'), (2,'union-2'), (3, 'union-3'), (4, 'union-4');
TRUNCATE TABLE test.suppliers_trade_unions CASCADE;
INSERT INTO test.suppliers_trade_unions (supplier_id, trade_union_id) VALUES (1,1), (1,2), (2,3), (2,4);
+5
View File
@@ -151,6 +151,11 @@ GRANT ALL ON TABLE
, schauspieler
, filme
, rollen
, products
, suppliers
, products_suppliers
, trade_unions
, suppliers_trade_unions
TO postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
+27
View File
@@ -2284,3 +2284,30 @@ $$ language sql;
create or replace function test.overloaded_unnamed_param(x int, y int) returns int as $$
select x + y;
$$ language sql;
create table products(
id int primary key
, name text
);
create table suppliers(
id int primary key
, name text
);
create table products_suppliers(
product_id int references products(id),
supplier_id int references suppliers(id),
primary key (product_id, supplier_id)
);
create table trade_unions(
id int primary key
, name text
);
create table suppliers_trade_unions(
supplier_id int references suppliers(id),
trade_union_id int references trade_unions(id),
primary key (supplier_id, trade_union_id)
);
@@ -12,6 +12,7 @@ db-schemas = "provided_through_alias"
db-config = "false"
db-tx-end = "commit"
db-uri = "required"
db-embed-default-join = "left"
jwt-aud = ""
jwt-role-claim-key = ".\"aliased\""
jwt-secret = ""
@@ -12,6 +12,7 @@ db-schemas = "required"
db-config = "false"
db-tx-end = "commit"
db-uri = "required"
db-embed-default-join = "left"
jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-secret = ""
@@ -12,6 +12,7 @@ db-schemas = "required"
db-config = "false"
db-tx-end = "commit"
db-uri = "required"
db-embed-default-join = "left"
jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-secret = ""
@@ -12,6 +12,7 @@ db-schemas = "required"
db-config = "false"
db-tx-end = "commit"
db-uri = "required"
db-embed-default-join = "left"
jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-secret = ""
@@ -12,6 +12,7 @@ db-schemas = "test,other_tenant1,other_tenant2"
db-config = "true"
db-tx-end = "rollback-allow-override"
db-uri = "<REPLACED_WITH_DB_URI>"
db-embed-default-join = "inner"
jwt-aud = "https://otherexample.org"
jwt-role-claim-key = ".\"other\".\"role\""
jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE"
@@ -12,6 +12,7 @@ db-schemas = "test,tenant1,tenant2"
db-config = "true"
db-tx-end = "commit-allow-override"
db-uri = "<REPLACED_WITH_DB_URI>"
db-embed-default-join = "inner"
jwt-aud = "https://example.org"
jwt-role-claim-key = ".\"a\".\"role\""
jwt-secret = "OVERRIDEREALLYREALLYREALLYREALLYVERYSAFE"
@@ -12,6 +12,7 @@ db-schemas = "multi,tenant,setup"
db-config = "false"
db-tx-end = "rollback-allow-override"
db-uri = "tmp_db"
db-embed-default-join = "inner"
jwt-aud = "https://postgrest.org"
jwt-role-claim-key = ".\"user\"[0].\"real-role\""
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
@@ -12,6 +12,7 @@ db-schemas = "required"
db-config = "true"
db-tx-end = "commit"
db-uri = "required"
db-embed-default-join = "left"
jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-secret = ""
@@ -14,6 +14,7 @@ PGRST_DB_SCHEMAS: multi, tenant,setup
PGRST_DB_CONFIG: false
PGRST_DB_TX_END: rollback-allow-override
PGRST_DB_URI: tmp_db
PGRST_DB_EMBED_DEFAULT_JOIN: inner
PGRST_JWT_AUD: 'https://postgrest.org'
PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"'
PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5
+1
View File
@@ -12,6 +12,7 @@ db-schemas = "multi, tenant,setup"
db-config = "false"
db-tx-end = "rollback-allow-override"
db-uri = "tmp_db"
db-embed-default-join = "inner"
jwt-aud = "https://postgrest.org"
jwt-role-claim-key = ".user[0].\"real-role\""
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
+5
View File
@@ -179,3 +179,8 @@ invalidopenapimodes:
- 'follow-'
- 'ignore-'
- '.#$$%&$%/'
invalidjointypes:
- 'left!'
- 'right'
- '.#$$%&$%/'
+15
View File
@@ -440,6 +440,21 @@ def test_invalid_openapi_mode(invalidopenapimodes, defaultenv):
print(line)
@pytest.mark.parametrize("invalidjointypes", FIXTURES["invalidjointypes"])
def test_invalid_db_embed_default_join(invalidjointypes, defaultenv):
"Given an invalid db-embed-default-join, Postgrest should exit with a non-zero exit code."
env = {
**defaultenv,
"PGRST_DB_EMBED_DEFAULT_JOIN": invalidjointypes,
}
with pytest.raises(PostgrestError):
dump = dumpconfig(CONFIGSDIR / "defaults.config", env=env)
for line in dump.split("\n"):
if line.startswith("db-embed-default-join"):
print(line)
def test_iat_claim(defaultenv):
"""
A claim with an 'iat' (issued at) attribute should be successful.