server: support relationship column comparison with root table columns

GitOrigin-RevId: 9d9bcb82a997c1f060df5ba0726c29d934e70d71
This commit is contained in:
Karthikeyan Chinnakonda 2021-04-19 17:46:10 +05:30 committed by hasura-bot
parent 7638d1832a
commit 58cf521c62
25 changed files with 249 additions and 54 deletions

View File

@ -3,6 +3,17 @@
## Next release
(Add entries here in the order of: server, console, cli, docs, others)
### Support comparing columns across related tables in permission's boolean expressions
We now support comparing columns across related tables. For example:
Consider two tables, `items(id, name, quantity)` and `shopping_cart(id, item_id, quantity)`
and these two tables are related via the `item_id` column. Now, while defining insert permission
on the `shopping_cart` table, there can be a check to insert an item into the shopping cart
only when there are enough present in the items inventory.
### Bug fixes and improvements
- console: add bigquery support (#1000)
- cli: add support for bigquery in metadata operations

View File

@ -793,6 +793,48 @@ Operator
**Operators for comparing columns (all column types except json, jsonb):**
**Column Comparison Operator**
.. parsed-literal::
:class: haskell-pre
{
PGColumn_: {
Operator_: {
PGColumn_ | ["$", PGColumn_]
}
}
}
Column comparison operators can be used to compare columns of the same
table or a related table. To compare a column of a table with another column of :
1. The same table -
.. parsed-literal::
:class: haskell-pre
{
PGColumn_: {
Operator_: {
PGColumn_
}
}
}
2. The table on which the permission is being defined on -
.. parsed-literal::
:class: haskell-pre
{
PGColumn_: {
Operator_: {
[$, PGColumn_]
}
}
}
.. list-table::
:header-rows: 1

View File

@ -2,8 +2,8 @@ module Hasura.Backends.BigQuery.DDL.BoolExp where
import Hasura.Prelude
import qualified Data.Aeson as J
import qualified Data.HashMap.Strict as Map
import qualified Data.Aeson as J
import qualified Data.HashMap.Strict as Map
import Hasura.Backends.BigQuery.Instances.Types ()
import Hasura.Backends.BigQuery.Types
@ -18,11 +18,12 @@ parseBoolExpOperations
:: forall m v
. (MonadError QErr m)
=> ValueParser 'BigQuery m v
-> TableName
-> FieldInfoMap (FieldInfo 'BigQuery)
-> ColumnInfo 'BigQuery
-> J.Value
-> m [OpExpG 'BigQuery v]
parseBoolExpOperations rhsParser _fields columnInfo value =
parseBoolExpOperations rhsParser _table _fields columnInfo value =
withPathK (columnName $ pgiColumn columnInfo) $
parseOperations (pgiType columnInfo) value
where

View File

@ -21,11 +21,12 @@ parseBoolExpOperations
:: forall m v
. (MonadError QErr m) -- , TableCoreInfoRM 'MSSQL m)
=> ValueParser 'MSSQL m v
-> TableName
-> FieldInfoMap (FieldInfo 'MSSQL)
-> ColumnInfo 'MSSQL
-> J.Value
-> m [OpExpG 'MSSQL v]
parseBoolExpOperations rhsParser _fields columnInfo value =
parseBoolExpOperations rhsParser _table _fields columnInfo value =
withPathK (columnNameText $ pgiColumn columnInfo) $
parseOperations (pgiType columnInfo) value
where

View File

@ -39,13 +39,16 @@ instance ToTxt (ColumnReference 'Postgres) where
parseBoolExpOperations
:: forall m v
. (MonadError QErr m)
. ( MonadError QErr m
, TableCoreInfoRM 'Postgres m
)
=> ValueParser 'Postgres m v
-> QualifiedTable
-> FieldInfoMap (FieldInfo 'Postgres)
-> ColumnInfo 'Postgres
-> Value
-> m [OpExpG 'Postgres v]
parseBoolExpOperations rhsParser fim columnInfo value = do
parseBoolExpOperations rhsParser rootTable fim columnInfo value = do
restrictJSONColumn
withPathK (getPGColTxt $ pgiColumn columnInfo) $
parseOperations (ColumnReferenceColumn columnInfo) value
@ -211,12 +214,12 @@ parseBoolExpOperations rhsParser fim columnInfo value = do
parseGte = AGTE <$> parseOne -- >=
parseLte = ALTE <$> parseOne -- <=
parseCeq = CEQ <$> decodeAndValidateRhsCol
parseCne = CNE <$> decodeAndValidateRhsCol
parseCgt = CGT <$> decodeAndValidateRhsCol
parseClt = CLT <$> decodeAndValidateRhsCol
parseCgte = CGTE <$> decodeAndValidateRhsCol
parseClte = CLTE <$> decodeAndValidateRhsCol
parseCeq = CEQ <$> decodeAndValidateRhsCol val
parseCne = CNE <$> decodeAndValidateRhsCol val
parseCgt = CGT <$> decodeAndValidateRhsCol val
parseClt = CLT <$> decodeAndValidateRhsCol val
parseCgte = CGTE <$> decodeAndValidateRhsCol val
parseClte = CLTE <$> decodeAndValidateRhsCol val
parseLike = guardType stringTypes >> ALIKE <$> parseOne
parseNlike = guardType stringTypes >> ANLIKE <$> parseOne
@ -267,6 +270,33 @@ parseBoolExpOperations rhsParser fim columnInfo value = do
return $ ASTDWithinGeog $ DWithinGeogOp dist from useSpheroid
_ -> throwError $ buildMsg colTy [PGGeometry, PGGeography]
decodeAndValidateRhsCol :: Value -> m (PGCol, Maybe QualifiedTable)
decodeAndValidateRhsCol v@(String _) = do
j <- decodeValue v
col <- validateRhsCol fim j
pure (col, Nothing)
decodeAndValidateRhsCol (Array path) = do
case toList path of
[] -> throw400 Unexpected "path cannot be empty"
[col] -> do
j <- decodeValue col
col' <- validateRhsCol fim j
pure $ (col', Nothing)
(root : col : []) -> do
root' :: Text <- decodeValue root
unless (root' == "$") $
throw400 NotSupported "Relationship references are not supported in column comparison RHS"
rootTableInfo <-
lookupTableCoreInfo rootTable
>>= (`onNothing` (throw500 $ "unexpected: " <> rootTable <<> " doesn't exist"))
j <- decodeValue col
col' <- validateRhsCol (_tciFieldInfoMap rootTableInfo) j
pure $ (col', Just rootTable)
_ -> throw400 NotSupported "Relationship references are not supported in column comparison RHS"
decodeAndValidateRhsCol _ =
throw400 Unexpected "a boolean expression JSON can either be a string or an array"
parseST3DDWithinObj = ABackendSpecific <$> do
guardType [PGGeometry]
DWithinGeomOp distVal fromVal <- parseVal
@ -274,12 +304,9 @@ parseBoolExpOperations rhsParser fim columnInfo value = do
from <- withPathK "from" $ parseOneNoSess colTy fromVal
return $ AST3DDWithinGeom $ DWithinGeomOp dist from
decodeAndValidateRhsCol =
parseVal >>= validateRhsCol
validateRhsCol rhsCol = do
validateRhsCol fieldInfoMap rhsCol = do
let errMsg = "column operators can only compare postgres columns"
rhsType <- askColumnType fim rhsCol errMsg
rhsType <- askColumnType fieldInfoMap rhsCol errMsg
if colTy /= rhsType
then throw400 UnexpectedPayload $
"incompatible column types : " <> column <<> ", " <>> rhsCol

View File

@ -17,7 +17,6 @@ import Hasura.Backends.Postgres.Types.BoolExp
import Hasura.RQL.Types
import Hasura.SQL.Types
-- This convoluted expression instead of col = val
-- to handle the case of col : null
equalsBoolExpBuilder :: SQLExpression 'Postgres -> SQLExpression 'Postgres -> S.BoolExp
@ -37,38 +36,40 @@ notEqualsBoolExpBuilder qualColExp rhsExp =
annBoolExp
:: (QErrM m, TableCoreInfoRM b m, BackendMetadata b)
=> ValueParser b m v
-> TableName b
-> FieldInfoMap (FieldInfo b)
-> GBoolExp b ColExp
-> m (AnnBoolExp b v)
annBoolExp rhsParser fim boolExp =
annBoolExp rhsParser rootTable fim boolExp =
case boolExp of
BoolAnd exps -> BoolAnd <$> procExps exps
BoolOr exps -> BoolOr <$> procExps exps
BoolNot e -> BoolNot <$> annBoolExp rhsParser fim e
BoolNot e -> BoolNot <$> annBoolExp rhsParser rootTable fim e
BoolExists (GExists refqt whereExp) ->
withPathK "_exists" $ do
refFields <- withPathK "_table" $ askFieldInfoMapSource refqt
annWhereExp <- withPathK "_where" $
annBoolExp rhsParser refFields whereExp
annBoolExp rhsParser rootTable refFields whereExp
return $ BoolExists $ GExists refqt annWhereExp
BoolFld fld -> BoolFld <$> annColExp rhsParser fim fld
BoolFld fld -> BoolFld <$> annColExp rhsParser rootTable fim fld
where
procExps = mapM (annBoolExp rhsParser fim)
procExps = mapM (annBoolExp rhsParser rootTable fim)
annColExp
:: (QErrM m, TableCoreInfoRM b m, BackendMetadata b)
=> ValueParser b m v
-> TableName b
-> FieldInfoMap (FieldInfo b)
-> ColExp
-> m (AnnBoolExpFld b v)
annColExp rhsParser colInfoMap (ColExp fieldName colVal) = do
annColExp rhsParser rootTable colInfoMap (ColExp fieldName colVal) = do
colInfo <- askFieldInfo colInfoMap fieldName
case colInfo of
FIColumn pgi -> AVCol pgi <$> parseBoolExpOperations rhsParser colInfoMap pgi colVal
FIColumn pgi -> AVCol pgi <$> parseBoolExpOperations rhsParser rootTable colInfoMap pgi colVal
FIRelationship relInfo -> do
relBoolExp <- decodeValue colVal
relFieldInfoMap <- askFieldInfoMapSource $ riRTable relInfo
annRelBoolExp <- annBoolExp rhsParser relFieldInfoMap $
annRelBoolExp <- annBoolExp rhsParser rootTable relFieldInfoMap $
unBoolExp relBoolExp
return $ AVRel relInfo annRelBoolExp
FIComputedField _ ->
@ -139,7 +140,8 @@ mkFieldCompExp
:: S.Qual -> FieldName -> OpExpG 'Postgres S.SQLExp -> S.BoolExp
mkFieldCompExp qual lhsField = mkCompExp (mkQField lhsField)
where
mkQCol = S.SEQIdentifier . S.QIdentifier qual . toIdentifier
mkQCol (col, Nothing) = S.SEQIdentifier $ S.QIdentifier qual $ toIdentifier col
mkQCol (col, Just table) = S.SEQIdentifier $ S.mkQIdentifierTable table $ toIdentifier col
mkQField = S.SEQIdentifier . S.QIdentifier qual . Identifier . getFieldNameTxt
mkCompExp :: SQLExpression 'Postgres -> OpExpG 'Postgres (SQLExpression 'Postgres) -> S.BoolExp

View File

@ -76,7 +76,7 @@ procBoolExp
-> BoolExp b
-> m (AnnBoolExpPartialSQL b, [SchemaDependency])
procBoolExp source tn fieldInfoMap be = do
abe <- annBoolExp parseCollectableType fieldInfoMap $ unBoolExp be
abe <- annBoolExp parseCollectableType tn fieldInfoMap $ unBoolExp be
let deps = getBoolExpDeps source tn abe
return (abe, deps)

View File

@ -91,7 +91,7 @@ validateCountQWith sessVarBldr prepValBldr (CountQuery qt _ mDistCols mWhere) =
-- convert the where clause
annSQLBoolExp <- forM mWhere $ \be ->
withPathK "where" $
convBoolExp colInfoMap selPerm be sessVarBldr (valueParserWithCollectableType prepValBldr)
convBoolExp colInfoMap selPerm be sessVarBldr qt (valueParserWithCollectableType prepValBldr)
resolvedSelFltr <- convAnnBoolExpPartialSQL sessVarBldr $
spiFilter selPerm

View File

@ -68,7 +68,7 @@ validateDeleteQWith sessVarBldr prepValBldr
-- convert the where clause
annSQLBoolExp <- withPathK "where" $
convBoolExp fieldInfoMap selPerm rqlBE sessVarBldr (valueParserWithCollectableType prepValBldr)
convBoolExp fieldInfoMap selPerm rqlBE sessVarBldr tableName (valueParserWithCollectableType prepValBldr)
resolvedDelFltr <- convAnnBoolExpPartialSQL sessVarBldr $
dpiFilter delPerm

View File

@ -297,10 +297,11 @@ convBoolExp
-> SelPermInfo b
-> BoolExp b
-> SessVarBldr b m
-> TableName b
-> ValueParser b m (SQLExpression b)
-> m (AnnBoolExpSQL b)
convBoolExp cim spi be sessVarBldr rhsParser = do
abe <- annBoolExp rhsParser cim $ unBoolExp be
convBoolExp cim spi be sessVarBldr rootTable rhsParser = do
abe <- annBoolExp rhsParser rootTable cim $ unBoolExp be
checkSelPerm spi sessVarBldr abe
dmlTxErrorHandler :: Q.PGTxErr -> QErr

View File

@ -206,7 +206,7 @@ convSelectQ table fieldInfoMap selPermInfo selQ sessVarBldr prepValBldr = do
-- Convert where clause
wClause <- forM (sqWhere selQ) $ \boolExp ->
withPathK "where" $
convBoolExp fieldInfoMap selPermInfo boolExp sessVarBldr prepValBldr
convBoolExp fieldInfoMap selPermInfo boolExp sessVarBldr table prepValBldr
annFlds <- withPathK "columns" $
indexedForM (sqColumns selQ) $ \case

View File

@ -155,7 +155,7 @@ validateUpdateQueryWith sessVarBldr prepValBldr uq = do
-- convert the where clause
annSQLBoolExp <- withPathK "where" $
convBoolExp fieldInfoMap selPerm (uqWhere uq) sessVarBldr prepValBldr
convBoolExp fieldInfoMap selPerm (uqWhere uq) sessVarBldr tableName prepValBldr
resolvedUpdFltr <- convAnnBoolExpPartialSQL sessVarBldr $
upiFilter updPerm

View File

@ -235,12 +235,17 @@ data OpExpG (b :: BackendType) a
| ALIKE !a -- LIKE
| ANLIKE !a -- NOT LIKE
| CEQ !(Column b)
| CNE !(Column b)
| CGT !(Column b)
| CLT !(Column b)
| CGTE !(Column b)
| CLTE !(Column b)
-- column comparison operators, the (Maybe (TableName b))
-- is for setting the root table if there's a comparison
-- of a relationship column with a column of the root table
-- it will be set, otherwise it will be Nothing
| CEQ !(Column b, Maybe (TableName b))
| CNE !(Column b, Maybe (TableName b))
| CGT !(Column b, Maybe (TableName b))
| CLT !(Column b, Maybe (TableName b))
| CGTE !(Column b, Maybe (TableName b))
| CLTE !(Column b, Maybe (TableName b))
| ANISNULL -- IS NULL
| ANISNOTNULL -- IS NOT NULL
@ -284,7 +289,7 @@ instance (Backend b, ToJSONKeyValue (BooleanOperators b a), ToJSON a) => ToJSONK
ABackendSpecific b -> toJSONKeyValue b
opExpDepCol :: OpExpG backend a -> Maybe (Column backend)
opExpDepCol :: OpExpG backend a -> Maybe (Column backend, Maybe (TableName backend))
opExpDepCol = \case
CEQ c -> Just c
CNE c -> Just c

View File

@ -178,6 +178,7 @@ data ColumnInfo (b :: BackendType)
, pgiDescription :: !(Maybe G.Description)
} deriving (Generic)
deriving instance Backend b => Eq (ColumnInfo b)
deriving instance Backend b => Show (ColumnInfo b)
instance Backend b => Cacheable (ColumnInfo b)
instance Backend b => NFData (ColumnInfo b)
instance Backend b => Hashable (ColumnInfo b)

View File

@ -110,6 +110,7 @@ data ComputedFieldInfo (b :: BackendType)
, _cfiComment :: !(Maybe Text)
} deriving (Generic)
deriving instance (Backend b) => Eq (ComputedFieldInfo b)
deriving instance (Backend b) => Show (ComputedFieldInfo b)
instance (Backend b) => Cacheable (ComputedFieldInfo b)
instance (Backend b) => ToJSON (ComputedFieldInfo b) where
-- spelling out the JSON instance in order to skip the Trees That Grow field

View File

@ -86,6 +86,7 @@ class (Backend b) => BackendMetadata (b :: BackendType) where
parseBoolExpOperations
:: (MonadError QErr m, TableCoreInfoRM b m)
=> ValueParser b m v
-> TableName b
-> FieldInfoMap (FieldInfo b)
-> ColumnInfo b
-> Value

View File

@ -75,6 +75,7 @@ data RemoteFieldInfo (b :: BackendType)
-- ^ Name of the table and its source
} deriving (Generic)
deriving instance Backend b => Eq (RemoteFieldInfo b)
deriving instance Backend b => Show (RemoteFieldInfo b)
instance Backend b => Cacheable (RemoteFieldInfo b)
graphQLValueToJSON :: G.Value Void -> Value

View File

@ -484,20 +484,23 @@ getColExpDeps
-> TableName b
-> AnnBoolExpFld b (PartialSQLExp b)
-> [SchemaDependency]
getColExpDeps source tn = \case
getColExpDeps source tableName = \case
AVCol colInfo opExps ->
let cn = pgiColumn colInfo
let columnName = pgiColumn colInfo
colDepReason = bool DRSessionVariable DROnType $ any hasStaticExp opExps
colDep = mkColDep colDepReason source tn cn
colDep = mkColDep colDepReason source tableName columnName
depColsInOpExp = mapMaybe opExpDepCol opExps
colDepsInOpExp = map (mkColDep DROnType source tn) depColsInOpExp
colDepsInOpExp = do
(col, rootTable) <- depColsInOpExp
pure $ mkColDep DROnType source (fromMaybe tableName rootTable) col
in colDep:colDepsInOpExp
AVRel relInfo relBoolExp ->
let rn = riName relInfo
relTN = riRTable relInfo
pd = SchemaDependency
(SOSourceObj source
$ AB.mkAnyBackend
$ SOITableObj tn (TORel rn))
DROnType
in pd : getBoolExpDeps source relTN relBoolExp
let relationshipName = riName relInfo
relationshipTable = riRTable relInfo
schemaDependency =
SchemaDependency
(SOSourceObj source
$ AB.mkAnyBackend
$ SOITableObj tableName (TORel relationshipName))
DROnType
in schemaDependency : getBoolExpDeps source relationshipTable relBoolExp

View File

@ -95,6 +95,7 @@ data FieldInfo (b :: BackendType)
| FIComputedField !(ComputedFieldInfo b)
| FIRemoteRelationship !(RemoteFieldInfo b)
deriving (Generic)
deriving instance Backend b => Show (FieldInfo b)
deriving instance Backend b => Eq (FieldInfo b)
instance Backend b => Cacheable (FieldInfo b)
instance Backend b => ToJSON (FieldInfo b) where

View File

@ -0,0 +1,36 @@
# Column comparison of a relationship table's column with a compatible root table's column
- description: Trying to order more number of an item than present in the inventory should fail
url: /v1/graphql
status: 200
headers:
X-Hasura-Role: user
response:
errors:
- extensions:
path: $.selectionSet.insert_order_cart.args.objects
code: permission-error
message: check constraint of an insert/update permission has failed
query:
query: |
mutation {
insert_order_cart( objects: { item_id: 1, quantity: 106 }) {
affected_rows
}
}
- description: When the order quantity is less than the item's inventory quantity, it should succeed
url: /v1/graphql
status: 200
headers:
X-Hasura-Role: user
response:
data:
insert_order_cart:
affected_rows: 1
query:
query: |
mutation {
insert_order_cart( objects: { item_id: 1, quantity: 6 }) {
affected_rows
}
}

View File

@ -83,6 +83,17 @@ args:
name text not null,
added_by text not null
);
create table items (
id serial primary key,
name text,
quantity int
);
create table order_cart (
id serial primary key,
item_id int not null references items(id),
quantity int
);
- type: track_table
args:
@ -538,3 +549,36 @@ args:
check:
name:
_ne: ''
- type: track_table
args:
schema: public
name: items
- type: track_table
args:
schema: public
name: order_cart
- type: create_object_relationship
args:
table: order_cart
name: item
using:
foreign_key_constraint_on: item_id
# an user can add an item in the order_cart
# iff there are enough of the them present
- type: create_insert_permission
args:
table: order_cart
role: user
permission:
columns: [item_id, quantity]
check:
item:
quantity:
_cgte:
- $
- quantity
set: {}

View File

@ -22,4 +22,6 @@ args:
drop table "user";
drop table account;
drop table leads;
drop table order_cart;
drop table items;
cascade: true

View File

@ -49,4 +49,11 @@ args:
name: Resident 6
age: 22
#Insert users
- type: insert
args:
table: items
objects:
- name: Apsara Pencil
quantity: 10
- name: Reynolds Trimax
quantity: 1323

View File

@ -24,3 +24,8 @@ args:
delete from "user";
SELECT setval('"user_id_seq"', 1, FALSE);
delete from order_cart;
delete from items;
SELECT setval('items_id_seq', 1, FALSE);

View File

@ -205,6 +205,9 @@ class TestGraphqlInsertPermission:
def test_check_set_headers_while_doing_upsert(self,hge_ctx):
check_query_f(hge_ctx, self.dir() + "/leads_upsert_check_with_headers.yaml")
def test_column_comparison_across_different_tables(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + "/column_comparison_across_tables.yaml")
@classmethod
def dir(cls):
return "queries/graphql_mutation/insert/permissions"