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:
committed by
Steve Chavez
parent
078c6ec08c
commit
0a1564ba5a
@@ -104,6 +104,47 @@ spec = describe "computed relationships" $ do
|
||||
[json|[ {"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}} ]|]
|
||||
{ matchStatus = 200 }
|
||||
|
||||
it "applies data representations to response" $ do
|
||||
-- A smoke test for data reps in the presence of computed relations.
|
||||
|
||||
-- The data rep here title cases the designer name before presentation. So here the lowercase version will be saved,
|
||||
-- but the title case version returned. Pulling in a computed relation should not confuse this.
|
||||
request methodPatch "/designers?select=name,videogames:computed_videogames(name)&id=eq.1"
|
||||
[("Prefer", "return=representation"), ("Prefer", "tx=commit")]
|
||||
[json| {"name": "sidney k. meier"} |]
|
||||
`shouldRespondWith`
|
||||
[json|[{"name":"Sidney K. Meier","videogames":[{"name":"Civilization I"}, {"name":"Civilization II"}]}]|]
|
||||
{ matchStatus = 200 }
|
||||
|
||||
-- Verify it was saved the way we requested (there's no text data rep for this column, so if we select with the wrong casing, it should fail.)
|
||||
get "/designers?select=id&name=eq.Sidney%20K.%20Meier"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
{ matchStatus = 200, matchHeaders = [matchContentTypeJson] }
|
||||
-- But with the right casing it works.
|
||||
get "/designers?select=id,name&name=eq.sidney%20k.%20meier"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id": 1, "name":"Sidney K. Meier"}]|]
|
||||
{ matchStatus = 200, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
-- Most importantly, if you read it back even via a computed relation, the data rep should be applied.
|
||||
get "/videogames?select=name,designer:computed_designers(*)&id=eq.1"
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Civilization I","designer":{"id": 1, "name":"Sidney K. Meier"}}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
-- reset the test fixture
|
||||
request methodPatch "/designers?id=eq.1"
|
||||
[("Prefer", "tx=commit")]
|
||||
[json| {"name": "Sid Meier"} |]
|
||||
`shouldRespondWith` 204
|
||||
-- need to poke the second one too to prevent inherent ordering from changing
|
||||
request methodPatch "/designers?id=eq.2"
|
||||
[("Prefer", "tx=commit")]
|
||||
[json| {"name": "Hironobu Sakaguchi"} |]
|
||||
`shouldRespondWith` 204
|
||||
|
||||
it "works with self joins" $
|
||||
get "/web_content?select=name,child_web_content(name),parent_web_content(name)&id=in.(0,1)"
|
||||
`shouldRespondWith`
|
||||
|
||||
@@ -742,3 +742,114 @@ spec actualPgVersion = do
|
||||
, "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
-- Data representations for payload parsing requires Postgres 10 or above.
|
||||
when (actualPgVersion >= pgVersion100) $ do
|
||||
describe "Data representations" $ do
|
||||
context "on regular table" $ do
|
||||
it "parses values in POST body" $
|
||||
-- we don't check that the parsing is correct here, just that it's happening. If it doesn't happen we'll get a
|
||||
-- an "invalid input syntax for type integer:" error.
|
||||
request methodPost "/datarep_todos" [("Prefer", "return=headers-only")]
|
||||
[json| {"id":5, "name": "party", "label_color": "#001100", "due_at": "2018-01-03T11:00:00+00"} |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/datarep_todos?id=eq.5"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
it "parses values in POST body and formats individually selected values in return=representation" $
|
||||
request methodPost "/datarep_todos?select=id,label_color" [("Prefer", "return=representation")]
|
||||
[json| {"id":5, "name": "party", "label_color": "#001100", "due_at": "2018-01-03T11:00:00+00"} |]
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":5, "label_color": "#001100"}] |]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
|
||||
"Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "parses values in POST body and formats values in return=representation" $
|
||||
request methodPost "/datarep_todos" [("Prefer", "return=representation")]
|
||||
[json| {"id":5, "name": "party", "label_color": "#001100", "due_at": "2018-01-03T11:00:00+00", "icon_image": "3q2+7w", "created_at":-15, "budget": "-100000000000000.13"} |]
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":5,"name": "party", "label_color": "#001100", "due_at":"2018-01-03T11:00:00Z", "icon_image": "3q2+7w==", "created_at":-15, "budget": "-100000000000000.13"}] |]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
|
||||
"Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
context "with ?columns parameter" $ do
|
||||
it "ignores json keys not included in ?columns; parses only the ones specified" $
|
||||
request methodPost "/datarep_todos?columns=id,label_color&select=id,name,label_color,due_at" [("Prefer", "return=representation")]
|
||||
[json| {"id":5, "name": "party", "label_color": "#001100", "due_at": "invalid but should be ignored"} |]
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":5, "name":null, "label_color": "#001100", "due_at": "2018-01-01T00:00:00Z"}] |]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
|
||||
"Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "fails without parsing anything if at least one specified column doesn't exist" $
|
||||
request methodPost "/datarep_todos?columns=id,label_color,helicopters&select=id,name,label_color,due_at" [("Prefer", "return=representation")]
|
||||
[json| {"due_at": "2019-01-03T11:00:00+00", "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"]
|
||||
}
|
||||
|
||||
context "on updatable view" $ do
|
||||
it "parses values in POST body" $
|
||||
-- we don't check that the parsing is correct here, just that it's happening. If it doesn't happen we'll get a
|
||||
-- an "invalid input syntax for type integer:" error.
|
||||
request methodPost "/datarep_todos_computed" [("Prefer", "return=headers-only")]
|
||||
[json| {"id":5, "name": "party", "label_color": "#001100", "due_at": "2018-01-03T11:00:00+00"} |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/datarep_todos_computed?id=eq.5"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
it "parses values in POST body and formats individually selected values in return=representation" $
|
||||
request methodPost "/datarep_todos_computed?select=id,label_color" [("Prefer", "return=representation")]
|
||||
[json| {"id":5, "name": "party", "label_color": "#001100", "due_at": "2018-01-03T11:00:00+00"} |]
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":5, "label_color": "#001100"}] |]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
|
||||
"Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "parses values in POST body and formats values in return=representation" $
|
||||
request methodPost "/datarep_todos_computed" [("Prefer", "return=representation")]
|
||||
[json| {"id":5, "name": "party", "label_color": "#001100", "due_at": "2018-01-03T11:00:00+00"} |]
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":5,"name": "party", "label_color": "#001100", "due_at":"2018-01-03T11:00:00Z", "dark_color":"#000880"}] |]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
|
||||
"Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
context "on updatable views with ?columns parameter" $ do
|
||||
it "ignores json keys not included in ?columns; parses only the ones specified" $
|
||||
request methodPost "/datarep_todos_computed?columns=id,label_color&select=id,name,label_color,due_at" [("Prefer", "return=representation")]
|
||||
[json| {"id":5, "name": "party", "label_color": "#001100", "due_at": "invalid but should be ignored"} |]
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":5, "name":null, "label_color": "#001100", "due_at": "2018-01-01T00:00:00Z"}] |]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8",
|
||||
"Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "fails without parsing anything if at least one specified column doesn't exist" $
|
||||
request methodPost "/datarep_todos_computed?columns=id,label_color,helicopters&select=id,name,label_color,due_at" [("Prefer", "return=representation")]
|
||||
[json| {"due_at": "2019-01-03T11:00:00+00", "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"]
|
||||
}
|
||||
|
||||
@@ -1328,3 +1328,122 @@ spec actualPgVersion = do
|
||||
get "/articles?body=imatch(any).{stop,thing}&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}, {"id":2}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
describe "Data representations for customisable value formatting and parsing" $ do
|
||||
it "formats a single column" $
|
||||
get "/datarep_todos?select=id,label_color&id=lt.4" `shouldRespondWith`
|
||||
[json| [{"id":1,"label_color":"#000000"},{"id":2,"label_color":"#000100"},{"id":3,"label_color":"#01E240"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "formats two columns with different formatters" $
|
||||
get "/datarep_todos?select=id,label_color,due_at&id=lt.4" `shouldRespondWith`
|
||||
[json| [{"id":1,"label_color":"#000000","due_at":"2018-01-02T00:00:00Z"},{"id":2,"label_color":"#000100","due_at":"2018-01-03T00:00:00Z"},{"id":3,"label_color":"#01E240","due_at":"2018-01-01T14:12:34.123456Z"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "fails in some reasonable way when selecting fields that don't exist" $
|
||||
get "/datarep_todos?select=id,label_color,banana" `shouldRespondWith`
|
||||
[json| {"code":"42703","details":null,"hint":null,"message":"column datarep_todos.banana does not exist"} |]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
it "formats columns in views including computed columns" $
|
||||
get "/datarep_todos_computed?select=id,label_color,dark_color" `shouldRespondWith`
|
||||
[json| [
|
||||
{"id":1, "label_color":"#000000", "dark_color":"#000000"},
|
||||
{"id":2, "label_color":"#000100", "dark_color":"#000080"},
|
||||
{"id":3, "label_color":"#01E240", "dark_color":"#00F120"},
|
||||
{"id":4, "label_color":"", "dark_color":""}
|
||||
] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "formats and allows rename" $
|
||||
get "/datarep_todos?select=id,clr:label_color&id=lt.4" `shouldRespondWith`
|
||||
[json| [{"id":1,"clr":"#000000"},{"id":2,"clr":"#000100"},{"id":3,"clr":"#01E240"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "formats, renames and allows manual casting on top" $
|
||||
get "/datarep_todos?select=id,clr:label_color::text&id=lt.4" `shouldRespondWith`
|
||||
[json| [{"id":1,"clr":"\"#000000\""},{"id":2,"clr":"\"#000100\""},{"id":3,"clr":"\"#01E240\""}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "formats nulls" $
|
||||
-- due_at is formatted as NULL but label_color NULLs become empty strings-- it's up to the formatting function.
|
||||
get "/datarep_todos?select=id,label_color,due_at&id=gt.2&id=lt.5" `shouldRespondWith`
|
||||
[json| [{"id":3,"label_color":"#01E240","due_at":"2018-01-01T14:12:34.123456Z"},{"id":4,"label_color":"","due_at":null}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "formats star select" $
|
||||
get "/datarep_todos?select=*&id=lt.4" `shouldRespondWith`
|
||||
[json| [
|
||||
{"id":1,"name":"Report","label_color":"#000000","due_at":"2018-01-02T00:00:00Z","icon_image":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAAABBJREFUeJxiYAEAAAAA//8DAAAABgAFBXv6vUAAAAAASUVORK5CYII=","created_at":1513213350,"budget":"12.50"},
|
||||
{"id":2,"name":"Essay","label_color":"#000100","due_at":"2018-01-03T00:00:00Z","icon_image":null,"created_at":1513213350,"budget":"100000000000000.13"},
|
||||
{"id":3,"name":"Algebra","label_color":"#01E240","due_at":"2018-01-01T14:12:34.123456Z","icon_image":null,"created_at":1513213350,"budget":"0.00"}
|
||||
] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "formats implicit star select" $
|
||||
get "/datarep_todos?id=lt.4" `shouldRespondWith`
|
||||
[json| [
|
||||
{"id":1,"name":"Report","label_color":"#000000","due_at":"2018-01-02T00:00:00Z","icon_image":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAAABBJREFUeJxiYAEAAAAA//8DAAAABgAFBXv6vUAAAAAASUVORK5CYII=","created_at":1513213350,"budget":"12.50"},
|
||||
{"id":2,"name":"Essay","label_color":"#000100","due_at":"2018-01-03T00:00:00Z","icon_image":null,"created_at":1513213350,"budget":"100000000000000.13"},
|
||||
{"id":3,"name":"Algebra","label_color":"#01E240","due_at":"2018-01-01T14:12:34.123456Z","icon_image":null,"created_at":1513213350,"budget":"0.00"}
|
||||
] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "formats star and explicit mix" $
|
||||
get "/datarep_todos?select=due_at,*&id=lt.4" `shouldRespondWith`
|
||||
[json| [
|
||||
{"due_at":"2018-01-02T00:00:00Z","id":1,"name":"Report","label_color":"#000000","due_at":"2018-01-02T00:00:00Z","icon_image":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAAABBJREFUeJxiYAEAAAAA//8DAAAABgAFBXv6vUAAAAAASUVORK5CYII=","created_at":1513213350,"budget":"12.50"},
|
||||
{"due_at":"2018-01-03T00:00:00Z","id":2,"name":"Essay","label_color":"#000100","due_at":"2018-01-03T00:00:00Z","icon_image":null,"created_at":1513213350,"budget":"100000000000000.13"},
|
||||
{"due_at":"2018-01-01T14:12:34.123456Z","id":3,"name":"Algebra","label_color":"#01E240","due_at":"2018-01-01T14:12:34.123456Z","icon_image":null,"created_at":1513213350,"budget":"0.00"}
|
||||
] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "formats through join" $
|
||||
get "/datarep_next_two_todos?select=id,name,first_item:datarep_todos!datarep_next_two_todos_first_item_id_fkey(label_color,due_at)" `shouldRespondWith`
|
||||
[json| [{"id":1,"name":"school related","first_item":{"label_color":"#000100","due_at":"2018-01-03T00:00:00Z"}},{"id":2,"name":"do these first","first_item":{"label_color":"#000000","due_at":"2018-01-02T00:00:00Z"}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "formats through join with star select" $
|
||||
get "/datarep_next_two_todos?select=id,name,second_item:datarep_todos!datarep_next_two_todos_second_item_id_fkey(*)" `shouldRespondWith`
|
||||
[json| [
|
||||
{"id":1,"name":"school related","second_item":{"id":3,"name":"Algebra","label_color":"#01E240","due_at":"2018-01-01T14:12:34.123456Z","icon_image":null,"created_at":1513213350,"budget":"0.00"}},
|
||||
{"id":2,"name":"do these first","second_item":{"id":3,"name":"Algebra","label_color":"#01E240","due_at":"2018-01-01T14:12:34.123456Z","icon_image":null,"created_at":1513213350,"budget":"0.00"}}
|
||||
] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "uses text parser on value for filter given through query parameters" $
|
||||
get "/datarep_todos?select=id,due_at&label_color=eq.000100" `shouldRespondWith`
|
||||
[json| [{"id":2,"due_at":"2018-01-03T00:00:00Z"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "in the absense of text parser, does not try to use the JSON parser for query parameters" $
|
||||
get "/datarep_todos?select=id,due_at&due_at=eq.Z" `shouldRespondWith`
|
||||
-- we prove the parser is not used because it'd replace the Z with `+00:00` and a different error message.
|
||||
[json| {"code":"22007","details":null,"hint":null,"message":"invalid input syntax for type timestamp with time zone: \"Z\""} |]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
-- Before PG 11, this will fail because we need arrays of domain type values. The docs should explain data reps are
|
||||
-- not supported in this case.
|
||||
when (actualPgVersion >= pgVersion110) $ do
|
||||
it "uses text parser for filter with 'IN' predicates" $
|
||||
get "/datarep_todos?select=id,due_at&label_color=in.(000100,01E240)" `shouldRespondWith`
|
||||
[json| [
|
||||
{"id":2, "due_at": "2018-01-03T00:00:00Z"},
|
||||
{"id":3, "due_at": "2018-01-01T14:12:34.123456Z"}
|
||||
] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "uses text parser for filter with 'NOT IN' predicates" $
|
||||
get "/datarep_todos?select=id,due_at&label_color=not.in.(000000,01E240)" `shouldRespondWith`
|
||||
[json| [
|
||||
{"id":2, "due_at": "2018-01-03T00:00:00Z"}
|
||||
] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "uses text parser on value for filter across relations" $
|
||||
get "/datarep_next_two_todos?select=id,name,datarep_todos!datarep_next_two_todos_first_item_id_fkey(label_color,due_at)&datarep_todos.label_color=neq.000100" `shouldRespondWith`
|
||||
[json| [{"id":1,"name":"school related","datarep_todos":null},{"id":2,"name":"do these first","datarep_todos":{"label_color":"#000000","due_at":"2018-01-02T00:00:00Z"}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
-- This is not supported by data reps (would be hard to make it work with high performance). So the test just
|
||||
-- verifies we don't panic or add inappropriate SQL to the filters.
|
||||
it "fails safely on user trying to use ilike operator on data reps column" $
|
||||
get "/datarep_todos?select=id,name&label_color=ilike.#*100" `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion110 then
|
||||
[json|
|
||||
{"code":"42883","details":null,"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.","message":"operator does not exist: public.color ~~* unknown"}
|
||||
|]
|
||||
else
|
||||
[json|
|
||||
{"code":"42883","details":null,"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.","message":"operator does not exist: public.color ~~* unknown"}
|
||||
|])
|
||||
{ matchStatus = 404
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -139,7 +139,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)
|
||||
|
||||
Vendored
+10
@@ -839,3 +839,13 @@ INSERT INTO posters(id,name) VALUES (1,'Mark'), (2,'Elon'), (3,'Bill'), (4,'Jeff
|
||||
|
||||
TRUNCATE TABLE subscriptions CASCADE;
|
||||
INSERT INTO subscriptions(subscriber,subscribed) VALUES (3,1), (4,1), (1,2);
|
||||
|
||||
TRUNCATE TABLE datarep_todos CASCADE;
|
||||
INSERT INTO datarep_todos VALUES (1, 'Report', 0, '2018-01-02', '\x89504e470d0a1a0a0000000d4948445200000001000000010100000000376ef924000000001049444154789c62600100000000ffff03000000060005057bfabd400000000049454e44ae426082', '2017-12-14 01:02:30', 12.50); -- smallest possible PNG
|
||||
INSERT INTO datarep_todos VALUES (2, 'Essay', 256, '2018-01-03', NULL, '2017-12-14 01:02:30', 100000000000000.13); -- a number which can't be represented by a 64-bit float
|
||||
INSERT INTO datarep_todos VALUES (3, 'Algebra', 123456, '2018-01-01 14:12:34.123456');
|
||||
INSERT INTO datarep_todos VALUES (4, 'Opus Magnum', NULL, NULL);
|
||||
|
||||
TRUNCATE TABLE datarep_next_two_todos CASCADE;
|
||||
INSERT INTO datarep_next_two_todos VALUES (1, 2, 3, 'school related');
|
||||
INSERT INTO datarep_next_two_todos VALUES (2, 1, 3, 'do these first');
|
||||
|
||||
Vendored
+142
-1
@@ -2774,9 +2774,20 @@ BEGIN
|
||||
LOAD 'safeupdate';
|
||||
END; $$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- This tests data representations over computed joins: even a lower case title should come back title cased.
|
||||
DROP DOMAIN IF EXISTS public.titlecasetext CASCADE;
|
||||
CREATE DOMAIN public.titlecasetext AS text;
|
||||
|
||||
CREATE OR REPLACE FUNCTION json(public.titlecasetext) RETURNS json AS $$
|
||||
SELECT to_json(INITCAP($1::text));
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE CAST (public.titlecasetext AS json) WITH FUNCTION json(public.titlecasetext) AS IMPLICIT;
|
||||
-- End of data representations specific stuff except for where the domain is used in the table.
|
||||
|
||||
CREATE TABLE designers (
|
||||
id int primary key
|
||||
, name text
|
||||
, name public.titlecasetext
|
||||
);
|
||||
|
||||
CREATE TABLE videogames (
|
||||
@@ -3091,6 +3102,136 @@ create table test.subscriptions(
|
||||
primary key(subscriber, subscribed)
|
||||
);
|
||||
|
||||
-- Data representations feature
|
||||
DROP DOMAIN IF EXISTS public.color CASCADE;
|
||||
CREATE DOMAIN public.color AS INTEGER CHECK (VALUE >= 0 AND VALUE <= 16777215);
|
||||
|
||||
CREATE OR REPLACE FUNCTION color(json) RETURNS public.color AS $$
|
||||
SELECT color($1 #>> '{}');
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION color(text) RETURNS public.color AS $$
|
||||
SELECT (('x' || lpad((CASE WHEN SUBSTRING($1::text, 1, 1) = '#' THEN SUBSTRING($1::text, 2) ELSE $1::text END), 8, '0'))::bit(32)::int)::public.color;
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION json(public.color) RETURNS json AS $$
|
||||
SELECT
|
||||
CASE WHEN $1 IS NULL THEN to_json(''::text)
|
||||
ELSE to_json('#' || lpad(upper(to_hex($1)), 6, '0'))
|
||||
END;
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE CAST (public.color AS json) WITH FUNCTION json(public.color) AS IMPLICIT;
|
||||
CREATE CAST (json AS public.color) WITH FUNCTION color(json) AS IMPLICIT;
|
||||
CREATE CAST (text AS public.color) WITH FUNCTION color(text) AS IMPLICIT;
|
||||
|
||||
DROP DOMAIN IF EXISTS public.isodate CASCADE;
|
||||
CREATE DOMAIN public.isodate AS timestamp with time zone;
|
||||
|
||||
CREATE OR REPLACE FUNCTION isodate(json) RETURNS public.isodate AS $$
|
||||
SELECT isodate($1 #>> '{}');
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION isodate(text) RETURNS public.isodate AS $$
|
||||
SELECT (replace($1, 'Z', '+00:00')::timestamp with time zone)::public.isodate;
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION json(public.isodate) RETURNS json AS $$
|
||||
SELECT to_json(replace(to_json($1)#>>'{}', '+00:00', 'Z'));
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE CAST (public.isodate AS json) WITH FUNCTION json(public.isodate) AS IMPLICIT;
|
||||
CREATE CAST (json AS public.isodate) WITH FUNCTION isodate(json) AS IMPLICIT;
|
||||
-- We intentionally don't have this in order to test query string parsing doesn't try to fall back on JSON parsing.
|
||||
-- CREATE CAST (text AS public.isodate) WITH FUNCTION isodate(text) AS IMPLICIT;
|
||||
|
||||
-- bytea_b64 is a base64-encoded binary string
|
||||
DROP DOMAIN IF EXISTS public.bytea_b64 CASCADE;
|
||||
CREATE DOMAIN public.bytea_b64 AS bytea;
|
||||
|
||||
CREATE OR REPLACE FUNCTION bytea_b64(json) RETURNS public.bytea_b64 AS $$
|
||||
SELECT bytea_b64($1 #>> '{}');
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION bytea_b64(text) RETURNS public.bytea_b64 AS $$
|
||||
-- allow unpadded base64
|
||||
SELECT decode($1 || repeat('=', 4 - (length($1) % 4)), 'base64')::public.bytea_b64;
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION json(public.bytea_b64) RETURNS json AS $$
|
||||
SELECT to_json(translate(encode($1, 'base64'), E'\n', ''));
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE CAST (public.bytea_b64 AS json) WITH FUNCTION json(public.bytea_b64) AS IMPLICIT;
|
||||
CREATE CAST (json AS public.bytea_b64) WITH FUNCTION bytea_b64(json) AS IMPLICIT;
|
||||
CREATE CAST (text AS public.bytea_b64) WITH FUNCTION bytea_b64(text) AS IMPLICIT;
|
||||
|
||||
-- unixtz is a timestamptz represented as an integer number of seconds since the Unix epoch
|
||||
DROP DOMAIN IF EXISTS public.unixtz CASCADE;
|
||||
CREATE DOMAIN public.unixtz AS timestamp with time zone;
|
||||
|
||||
CREATE OR REPLACE FUNCTION unixtz(json) RETURNS public.unixtz AS $$
|
||||
SELECT unixtz($1 #>> '{}');
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION unixtz(text) RETURNS public.unixtz AS $$
|
||||
SELECT (to_timestamp($1::numeric)::public.unixtz);
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION json(public.unixtz) RETURNS json AS $$
|
||||
SELECT to_json(extract(epoch from $1)::bigint);
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
|
||||
CREATE CAST (public.unixtz AS json) WITH FUNCTION json(public.unixtz) AS IMPLICIT;
|
||||
CREATE CAST (json AS public.unixtz) WITH FUNCTION unixtz(json) AS IMPLICIT;
|
||||
CREATE CAST (text AS public.unixtz) WITH FUNCTION unixtz(text) AS IMPLICIT;
|
||||
|
||||
DROP DOMAIN IF EXISTS public.monetary CASCADE;
|
||||
CREATE DOMAIN public.monetary AS numeric(17,2);
|
||||
|
||||
CREATE OR REPLACE FUNCTION monetary(json) RETURNS public.monetary AS $$
|
||||
SELECT monetary($1 #>> '{}');
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION monetary(text) RETURNS public.monetary AS $$
|
||||
SELECT ($1::numeric)::public.monetary;
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION json(public.monetary) RETURNS json AS $$
|
||||
SELECT to_json($1::text);
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
CREATE CAST (public.monetary AS json) WITH FUNCTION json(public.monetary) AS IMPLICIT;
|
||||
CREATE CAST (json AS public.monetary) WITH FUNCTION monetary(json) AS IMPLICIT;
|
||||
CREATE CAST (text AS public.monetary) WITH FUNCTION monetary(text) AS IMPLICIT;
|
||||
|
||||
CREATE TABLE datarep_todos (
|
||||
id bigint primary key,
|
||||
name text,
|
||||
label_color public.color default 0,
|
||||
due_at public.isodate default '2018-01-01'::date,
|
||||
icon_image public.bytea_b64,
|
||||
created_at public.unixtz default '2017-12-14 01:02:30'::timestamptz,
|
||||
budget public.monetary default 0
|
||||
);
|
||||
|
||||
CREATE TABLE datarep_next_two_todos (
|
||||
id bigint primary key,
|
||||
first_item_id bigint references datarep_todos(id),
|
||||
second_item_id bigint references datarep_todos(id),
|
||||
name text
|
||||
);
|
||||
|
||||
CREATE VIEW datarep_todos_computed as (
|
||||
SELECT id,
|
||||
name,
|
||||
label_color,
|
||||
due_at,
|
||||
(label_color / 2)::public.color as dark_color
|
||||
FROM datarep_todos
|
||||
);
|
||||
|
||||
-- view's name is alphabetically before projects
|
||||
create view test.alpha_projects as
|
||||
select c.id, p.name as pro_name, c.name as cli_name
|
||||
|
||||
Reference in New Issue
Block a user