feat: data representations allow custom parsing and formatting of API fields.

See PR #2523. Most notable code changes:

- Load data representation casts into schema cache.
- Data representations for reads, filters, inserts, updates, views, over joins.
- `CoercibleField` represents name references in queries where coercion may be needed.
- `ResolverContext` help facilitate field resolution during planning.
- Planner 'resolves' names in the API query and pairs them with any implicit conversions to be used in the query builder stage.
- Tests for all of the above.
- More consistent naming (TypedX -> CoercibleX).

New: unit tests for more data representation use cases; helpful as examples as well.

New: update CHANGELOG with data representations feature description.

Fixed failing idempotence test.

New: replace date formatter test with one that does something.

Fixup: inadvertent CHANGELOG change after rebase.

Cleanup: `tfName` -> `cfName` and related.

Document what IRType means.

Formatting.

New: use a subquery to interpret `IN` literals requiring data rep transformation.

- With the previous method, very long queries such as `ANY (ARRAY[test.color('000100'), test.color('CAFE12'), test.color('01E240'), ...` could be generated. Consider the case where the parser function name is 45 characters and there's a hundred literals. That's 4.5kB of SQL just for the function name alone!
- New version uses `unnest`: `ANY (SELECT test.color(unnest('{000100,CAFE12,01E240,...}'::text[]))` to produce a much shorter query.
- This is likely to be more performant and either way much more readable and debuggable in the logs.
This commit is contained in:
Alexander Ljungberg
2023-06-29 15:01:58 -05:00
committed by Steve Chavez
parent 078c6ec08c
commit 0a1564ba5a
17 changed files with 1045 additions and 163 deletions
+189 -2
View File
@@ -9,6 +9,9 @@ import Network.HTTP.Types
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import PostgREST.Config.PgVersion (PgVersion, pgVersion100)
import Protolude hiding (get)
import SpecHelper
@@ -18,8 +21,8 @@ tblDataBefore = [aesonQQ|[
, { "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" $
@@ -742,3 +745,187 @@ spec = do
, { "id": 2, "name": "updated-item" }
, { "id": 3, "name": "updated-item" }
]|]
-- Data representations for payload parsing requires Postgres 10 or above.
when (actualPgVersion >= pgVersion100) $ do
describe "Data representations" $ do
context "for a single row" $ do
it "parses values in payload" $
request methodPatch "/datarep_todos?id=eq.2" [("Prefer", "return=headers-only")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:00Z"} |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "0-0/*" ]
}
it "parses values in payload and formats individually selected values in return=representation" $
request methodPatch "/datarep_todos?id=eq.2&select=id,label_color" [("Prefer", "return=representation")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:00Z"} |]
`shouldRespondWith`
[json| [{"id":2, "label_color": "#221100"}] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-0/*"]
}
it "parses values in payload and formats values in return=representation" $
request methodPatch "/datarep_todos?id=eq.2" [("Prefer", "return=representation")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:20Z", "icon_image": "3q2+7w"} |]
`shouldRespondWith`
[json| [{"id":2,"name":"Essay","label_color":"#221100","due_at":"2019-01-03T11:00:20Z","icon_image":"3q2+7w==","created_at":1513213350,"budget":"100000000000000.13"}] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-0/*"]
}
it "parses values in payload and formats star mixed selected values in return=representation" $
request methodPatch "/datarep_todos?id=eq.2&select=due_at,*" [("Prefer", "return=representation")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:00Z", "created_at": 0} |]
`shouldRespondWith`
-- end up with due_at twice here but that's unrelated to data reps
[json| [{"due_at":"2019-01-03T11:00:00Z","id":2,"name":"Essay","label_color":"#221100","due_at":"2019-01-03T11:00:00Z","icon_image":null,"created_at":0,"budget":"100000000000000.13"}] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-0/*"]
}
context "for multiple rows" $ do
it "parses values in payload and formats individually selected values in return=representation" $
request methodPatch "/datarep_todos?id=lt.4&select=id,name,label_color" [("Prefer", "return=representation")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:00Z"} |]
`shouldRespondWith`
[json| [
{"id":1, "name": "Report", "label_color": "#221100"},
{"id":2, "name": "Essay", "label_color": "#221100"},
{"id":3, "name": "Algebra", "label_color": "#221100"}
] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-2/*"]
}
it "parses values in payload and formats values in return=representation" $
request methodPatch "/datarep_todos?id=lt.4" [("Prefer", "return=representation")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:00Z", "icon_image": "3q2+7w="} |]
`shouldRespondWith`
[json| [
{"id":1,"name":"Report","label_color":"#221100","due_at":"2019-01-03T11:00:00Z","icon_image":"3q2+7w==","created_at":1513213350,"budget":"12.50"},
{"id":2,"name":"Essay","label_color":"#221100","due_at":"2019-01-03T11:00:00Z","icon_image":"3q2+7w==","created_at":1513213350,"budget":"100000000000000.13"},
{"id":3,"name":"Algebra","label_color":"#221100","due_at":"2019-01-03T11:00:00Z","icon_image":"3q2+7w==","created_at":1513213350,"budget":"0.00"}
] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-2/*"]
}
context "with ?columns parameter" $ do
it "ignores json keys not included in ?columns; parses only the ones specified" $
request methodPatch "/datarep_todos?id=eq.2&columns=due_at" [("Prefer", "return=representation")]
[json| {"due_at": "2019-01-03T11:00:00Z", "smth": "here", "label_color": "invalid", "fake_id": 13} |]
`shouldRespondWith`
[json| [
{"id":2,"name":"Essay","label_color":"#000100","due_at":"2019-01-03T11:00:00Z","icon_image":null,"created_at":1513213350,"budget":"100000000000000.13"}
] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-0/*"]
}
it "fails if at least one specified column doesn't exist" $
request methodPatch "/datarep_todos?id=eq.2&columns=label_color,helicopters" [("Prefer", "return=representation")]
[json| {"due_at": "2019-01-03T11:00:00Z", "smth": "here", "label_color": "invalid", "fake_id": 13} |]
`shouldRespondWith`
[json| {"code":"PGRST204","message":"Column 'helicopters' of relation 'datarep_todos' does not exist","details":null,"hint":null} |]
{ matchStatus = 400
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
it "ignores json keys and gives 200 if no record updated" $
request methodPatch "/datarep_todos?id=eq.2001&columns=label_color" [("Prefer", "return=representation")]
[json| {"due_at": "2019-01-03T11:00:00Z", "smth": "here", "label_color": "invalid", "fake_id": 13} |]
`shouldRespondWith` 200
context "on a view" $ do
context "for a single row" $ do
it "parses values in payload" $
request methodPatch "/datarep_todos_computed?id=eq.2" [("Prefer", "return=headers-only")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:00Z"} |]
`shouldRespondWith`
""
{ matchStatus = 204
, matchHeaders = [ matchHeaderAbsent hContentType
, "Content-Range" <:> "0-0/*" ]
}
it "parses values in payload and formats individually selected values in return=representation" $
request methodPatch "/datarep_todos_computed?id=eq.2&select=id,label_color" [("Prefer", "return=representation")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:00Z"} |]
`shouldRespondWith`
[json| [{"id":2, "label_color": "#221100"}] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-0/*"]
}
it "parses values in payload and formats values in return=representation" $
request methodPatch "/datarep_todos_computed?id=eq.2" [("Prefer", "return=representation")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:20Z"} |]
`shouldRespondWith`
[json| [{"id":2, "name": "Essay", "label_color": "#221100", "dark_color":"#110880", "due_at":"2019-01-03T11:00:20Z"}] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-0/*"]
}
context "for multiple rows" $ do
it "parses values in payload and formats individually selected values in return=representation" $
request methodPatch "/datarep_todos_computed?id=lt.4&select=id,name,label_color,dark_color" [("Prefer", "return=representation")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:00Z"} |]
`shouldRespondWith`
[json| [
{"id":1, "name": "Report", "label_color": "#221100", "dark_color":"#110880"},
{"id":2, "name": "Essay", "label_color": "#221100", "dark_color":"#110880"},
{"id":3, "name": "Algebra", "label_color": "#221100", "dark_color":"#110880"}
] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-2/*"]
}
it "parses values in payload and formats values in return=representation" $
request methodPatch "/datarep_todos_computed?id=lt.4" [("Prefer", "return=representation")]
[json| {"label_color": "#221100", "due_at": "2019-01-03T11:00:00Z"} |]
`shouldRespondWith`
[json| [
{"id":1, "name": "Report", "label_color": "#221100", "dark_color":"#110880", "due_at":"2019-01-03T11:00:00Z"},
{"id":2, "name": "Essay", "label_color": "#221100", "dark_color":"#110880", "due_at":"2019-01-03T11:00:00Z"},
{"id":3, "name": "Algebra", "label_color": "#221100", "dark_color":"#110880", "due_at":"2019-01-03T11:00:00Z"}
] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-2/*"]
}
context "with ?columns parameter" $ do
it "ignores json keys not included in ?columns; parses only the ones specified" $
request methodPatch "/datarep_todos_computed?id=eq.2&columns=due_at" [("Prefer", "return=representation")]
[json| {"due_at": "2019-01-03T11:00:00Z", "smth": "here", "label_color": "invalid", "fake_id": 13} |]
`shouldRespondWith`
[json| [
{"id":2, "name": "Essay", "label_color": "#000100", "dark_color": "#000080", "due_at":"2019-01-03T11:00:00Z"}
] |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
"Content-Range" <:> "0-0/*"]
}
it "fails if at least one specified column doesn't exist" $
request methodPatch "/datarep_todos_computed?id=eq.2&columns=label_color,helicopters" [("Prefer", "return=representation")]
[json| {"due_at": "2019-01-03T11:00:00Z", "smth": "here", "label_color": "invalid", "fake_id": 13} |]
`shouldRespondWith`
[json| {"code":"PGRST204","message":"Column 'helicopters' of relation 'datarep_todos_computed' does not exist","details":null,"hint":null} |]
{ matchStatus = 400
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
it "ignores json keys and gives 200 if no record updated" $
request methodPatch "/datarep_todos_computed?id=eq.2001&columns=label_color" [("Prefer", "return=representation")]
[json| {"due_at": "2019-01-03T11:00:00Z", "smth": "here", "label_color": "invalid", "fake_id": 13} |]
`shouldRespondWith` 200