Skip to content

Commit

Permalink
feat: unset json keys as defaults on ?columns
Browse files Browse the repository at this point in the history
  • Loading branch information
steve-chavez committed Feb 19, 2023
1 parent a1e2fe3 commit 55f9854
Show file tree
Hide file tree
Showing 10 changed files with 120 additions and 30 deletions.
7 changes: 4 additions & 3 deletions src/PostgREST/Plan/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import Protolude

-- | A TypedField is a field with sufficient information to be read from JSON with `json_to_recordset`.
data TypedField = TypedField
{ tfName :: FieldName
, tfIRType :: Text -- ^ The initial type of the field, before any casting.
{ tfName :: FieldName
, tfIRType :: Text -- ^ The initial type of the field, before any casting.
, tfDefault :: Maybe Text
} deriving (Eq)

resolveTableField :: Table -> FieldName -> Maybe TypedField
resolveTableField table fieldName =
case HMI.lookup fieldName (tableColumns table) of
Just column -> Just $ TypedField (colName column) (colNominalType column)
Just column -> Just $ TypedField (colName column) (colNominalType column) (colDefault column)
Nothing -> Nothing
2 changes: 1 addition & 1 deletion src/PostgREST/Query/QueryBuilder.hs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ callPlanToQuery (FunctionCall qi params args returnsScalar multipleCall returnin
SQL.sql (
BS.unwords [
"pgrst_args AS (",
"SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <> fmtParams prms (const mempty) (\a -> " " <> encodeUtf8 (ppType a)) <> ")",
"SELECT * FROM jsonb_to_recordset(" <> selectBody <> ") AS _(" <> fmtParams prms (const mempty) (\a -> " " <> encodeUtf8 (ppType a)) <> ")",
")"])
, SQL.sql $ if multipleCall
then fmtParams prms varadicPrefix (\a -> " := pgrst_args." <> pgFmtIdent (ppName a))
Expand Down
24 changes: 17 additions & 7 deletions src/PostgREST/Query/SqlFragment.hs
Original file line number Diff line number Diff line change
Expand Up @@ -128,17 +128,17 @@ ftsOperator = \case
-- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body
normalizedBody :: Maybe LBS.ByteString -> SQL.Snippet
normalizedBody body =
"pgrst_payload AS (SELECT " <> jsonPlaceHolder <> " AS json_data), " <>
"pgrst_payload AS (SELECT " <> jsonbPlaceHolder <> " AS json_data), " <>
SQL.sql (BS.unwords [
"pgrst_body AS (",
"SELECT",
"CASE WHEN json_typeof(json_data) = 'array'",
"CASE WHEN jsonb_typeof(json_data) = 'array'",
"THEN json_data",
"ELSE json_build_array(json_data)",
"ELSE jsonb_build_array(json_data)",
"END AS val",
"FROM pgrst_payload)"])
where
jsonPlaceHolder = SQL.encoderAndParam (HE.nullable HE.jsonLazyBytes) body
jsonbPlaceHolder = SQL.encoderAndParam (HE.nullable HE.jsonbLazyBytes) body

singleParameter :: Maybe LBS.ByteString -> ByteString -> SQL.Snippet
singleParameter body typ =
Expand Down Expand Up @@ -251,12 +251,22 @@ pgFmtSelectFromJson fields =
-- When we are inserting no columns (e.g. using default values), we can't use our ordinary `json_to_recordset`
-- because it can't extract records with no columns (there's no valid syntax for the `AS (colName colType,...)`
-- part). But we still need to ensure as many rows are created as there are array elements.
then SQL.sql ("FROM json_array_elements (" <> selectBody <> ") _ ")
else SQL.sql ("FROM json_to_recordset (" <> selectBody <> ") AS _ " <> "(" <> typedCols <> ") ")
then SQL.sql ("FROM jsonb_array_elements (" <> selectBody <> ") _ ")
else SQL.sql $
"FROM (" <>
"select " <> defsJsonb <> " || elem AS elem_w_defs " <>
"from jsonb_array_elements(" <> selectBody <> ") elem " <>
") x " <>
"cross join jsonb_to_record(x.elem_w_defs) AS _ (" <> typedCols <> ") "
)
where
parsedCols = SQL.sql $ BS.intercalate ", " $ pgFmtIdent . tfName <$> fields
parsedCols = SQL.sql $ BS.intercalate ", " $ fromQi . QualifiedIdentifier "_" . tfName <$> fields
typedCols = BS.intercalate ", " $ pgFmtIdent . tfName <> const " " <> encodeUtf8 . tfIRType <$> fields
defsJsonb = "jsonb_build_object(" <> BS.intercalate "," fieldsWDefaults <> ")"
fieldsWDefaults = mapMaybe (\case
TypedField{tfName=nam, tfDefault=Just def} -> Just $ encodeUtf8 ("'" <> nam <> "', " <> def)
TypedField{tfDefault=Nothing} -> Nothing
) fields

pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SQL.Snippet
pgFmtOrderTerm qi ot =
Expand Down
57 changes: 50 additions & 7 deletions test/spec/Feature/Query/InsertSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc

import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
pgVersion112, pgVersion130)
import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
pgVersion110, pgVersion112,
pgVersion130)

import Protolude hiding (get)
import SpecHelper
Expand Down Expand Up @@ -449,15 +450,57 @@ spec actualPgVersion = do
333,
"asdf",
{"id": 205, "body": "zzz"}]|] `shouldRespondWith`
[json|{
"code": "22023",
"details": null,
"hint": null,
"message": "argument of json_to_recordset must be an array of objects"}|]
(if actualPgVersion < pgVersion100 then
[json|{
"code": "23502",
"details": "Failing row contains (id, body) = (null, null).",
"hint": null,
"message": "null value in column \"id\" violates not-null constraint"}|]
else
[json|{
"code": "22023",
"details": null,
"hint": null,
"message": "cannot call populate_composite on an array"}|]
)
{ matchStatus = 400
, matchHeaders = []
}

-- inserting the array fails on pg 9.6, but the feature should work normally
when (actualPgVersion >= pgVersion100) $
it "inserts table default values when json keys are undefined" $
request methodPost "/complex_items?columns=id,name,field-with_sep,arr_data" [("Prefer", "return=representation")]
[json|[
{"id": 4, "name": "Vier"},
{"id": 5, "name": "Funf"},
{"id": 6, "name": "Sechs", "field-with_sep": 6, "arr_data": "{1,2,3}"}
]|]
`shouldRespondWith`
[json|[
{"id": 4, "name": "Vier", "field-with_sep": 1, "settings":null,"arr_data":null},
{"id": 5, "name": "Funf", "field-with_sep": 1, "settings":null,"arr_data":null},
{"id": 6, "name": "Sechs", "field-with_sep": 6, "settings":null,"arr_data":[1,2,3]}
]|]
{ matchStatus = 201
, matchHeaders = []
}

it "inserts view default values when json keys are undefined" $
request methodPost "/complex_items_view?columns=id,name" [("Prefer", "return=representation")]
[json|[
{"id": 7, "name": "Sieben"},
{"id": 8}
]|]
`shouldRespondWith`
[json|[
{"id": 7, "name": "Sieben", "field-with_sep": 1, "settings":null,"arr_data":null},
{"id": 8, "name": "Default", "field-with_sep": 1, "settings":null,"arr_data":null}
]|]
{ matchStatus = 201
, matchHeaders = []
}

context "with unicode values" $ do
it "succeeds and returns full representation" $
request methodPost "/simple_pk2?select=extra,k"
Expand Down
12 changes: 6 additions & 6 deletions test/spec/Feature/Query/PlanSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ spec actualPgVersion = do
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe`
if actualPgVersion > pgVersion120
then 3.28
else 3.33
then 5.29
else 5.34

it "outputs the total cost for an update" $ do
r <- request methodPatch "/projects?id=eq.3"
Expand All @@ -184,8 +184,8 @@ spec actualPgVersion = do
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe`
if actualPgVersion > pgVersion120
then 12.45
else 12.5
then 14.46
else 14.51

it "outputs the total cost for a delete" $ do
r <- request methodDelete "/projects?id=in.(1,2,3)"
Expand Down Expand Up @@ -214,8 +214,8 @@ spec actualPgVersion = do
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe`
if actualPgVersion >= pgVersion120
then 1.3
else 1.35
then 5.54
else 5.59

it "outputs the plan for application/vnd.pgrst.object" $ do
r <- request methodDelete "/projects?id=eq.6"
Expand Down
4 changes: 2 additions & 2 deletions test/spec/Feature/Query/QuerySpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -327,11 +327,11 @@ spec actualPgVersion = do
describe "Shaping response with select parameter" $ do
it "selectStar works in absense of parameter" $
get "/complex_items?id=eq.3" `shouldRespondWith`
[json|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]
[json|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":3}]|]

it "dash `-` in column names is accepted" $
get "/complex_items?id=eq.3&select=id,field-with_sep" `shouldRespondWith`
[json|[{"id":3,"field-with_sep":1}]|]
[json|[{"id":3,"field-with_sep":3}]|]

it "one simple column" $
get "/complex_items?select=id" `shouldRespondWith`
Expand Down
35 changes: 33 additions & 2 deletions test/spec/Feature/Query/UpdateSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper

import PostgREST.Config.PgVersion (PgVersion, pgVersion100)


tblDataBefore = [aesonQQ|[
{ "id": 1, "name": "item-1" }
, { "id": 2, "name": "item-2" }
, { "id": 3, "name": "item-3" }
]|]

spec :: SpecWith ((), Application)
spec = do
spec :: PgVersion -> SpecWith ((), Application)
spec actualPgVersion = do
describe "Patching record" $ do
context "to unknown uri" $
it "indicates no table found by returning 404" $
Expand Down Expand Up @@ -330,6 +333,34 @@ spec = do
, matchHeaders = []
}

it "updates table default values when json keys are undefined" $ do
request methodPatch "/complex_items?id=eq.3&columns=name,field-with_sep"
[("Prefer", "return=representation")]
[json|{"name": "Tres"}|]
`shouldRespondWith`
[json|[
{"id":3,"name":"Tres","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}
]|]
{ matchStatus = 200
, matchHeaders = []
}

-- updating the array fails on pg 9.6, but the feature should work normally
when (actualPgVersion >= pgVersion100) $
it "updates view default values when json keys are undefined" $
request methodPatch "/complex_items_view?id=eq.3&columns=arr_data,name"
[("Prefer", "return=representation")]
[json|
{"arr_data":[6,6,6]}
|]
`shouldRespondWith`
[json|[
{"id":3,"name":"Default","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[6,6,6],"field-with_sep":3}
]|]
{ matchStatus = 200
, matchHeaders = []
}

context "tables with self reference foreign keys" $ do
it "embeds children after update" $
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"
Expand Down
2 changes: 1 addition & 1 deletion test/spec/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ main = do
, ("Feature.Query.RawOutputTypesSpec" , Feature.Query.RawOutputTypesSpec.spec)
, ("Feature.Query.RpcSpec" , Feature.Query.RpcSpec.spec actualPgVersion)
, ("Feature.Query.SingularSpec" , Feature.Query.SingularSpec.spec)
, ("Feature.Query.UpdateSpec" , Feature.Query.UpdateSpec.spec)
, ("Feature.Query.UpdateSpec" , Feature.Query.UpdateSpec.spec actualPgVersion)
, ("Feature.Query.UpsertSpec" , Feature.Query.UpsertSpec.spec actualPgVersion)
, ("Feature.Query.ComputedRelsSpec" , Feature.Query.ComputedRelsSpec.spec)
, ("Feature.Query.RelatedQueriesSpec" , Feature.Query.RelatedQueriesSpec.spec)
Expand Down
2 changes: 1 addition & 1 deletion test/spec/fixtures/data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ INSERT INTO touched_files VALUES
TRUNCATE TABLE complex_items CASCADE;
INSERT INTO complex_items VALUES (1, 'One', '{"foo":{"int":1,"bar":"baz"}}', '{1}');
INSERT INTO complex_items VALUES (2, 'Two', '{"foo":{"int":1,"bar":"baz"}}', '{1,2}');
INSERT INTO complex_items VALUES (3, 'Three', '{"foo":{"int":1,"bar":"baz"}}', '{1,2,3}');
INSERT INTO complex_items VALUES (3, 'Three', '{"foo":{"int":1,"bar":"baz"}}', '{1,2,3}', 3);


--
Expand Down
5 changes: 5 additions & 0 deletions test/spec/fixtures/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3112,3 +3112,8 @@ create view test.alpha_projects as
create view test.zeta_projects as
select c.id, p.name as pro_name, c.name as cli_name
from projects p join clients c on p.client_id = c.id;

CREATE VIEW test.complex_items_view AS
SELECT * FROM test.complex_items;

ALTER VIEW test.complex_items_view ALTER COLUMN name SET DEFAULT 'Default';

0 comments on commit 55f9854

Please sign in to comment.