feat: allow spreading one-to-many and many-to-many embedded resources
* Note: Aggregates are not implemented
This commit is contained in:
@@ -17,6 +17,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- #2255, Apply `to_tsvector()` explicitly to the full-text search filtered column (excluding `tsvector` types) - @laurenceisla
|
- #2255, Apply `to_tsvector()` explicitly to the full-text search filtered column (excluding `tsvector` types) - @laurenceisla
|
||||||
- #1578, Log the main SQL query to stderr at the current `log-level` when `log-query=main-query` - @laurenceisla
|
- #1578, Log the main SQL query to stderr at the current `log-level` when `log-query=main-query` - @laurenceisla
|
||||||
- #3903, Log connection pool borrows on `log-level=debug` - @taimoorzaeem
|
- #3903, Log connection pool borrows on `log-level=debug` - @taimoorzaeem
|
||||||
|
- #3041, Allow spreading one-to-many and many-to-many embedded resources - @laurenceisla
|
||||||
|
+ The selected columns in the embedded resources are aggregated into arrays
|
||||||
|
+ Aggregates are not supported
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -225,10 +225,10 @@ If we also want to get the total ``amount`` grouped by the ``order_date`` of the
|
|||||||
Note that the aggregate is done within the embedded resource ``orders``.
|
Note that the aggregate is done within the embedded resource ``orders``.
|
||||||
It is not affected by any of the columns from the top-level relationship ``customers``.
|
It is not affected by any of the columns from the top-level relationship ``customers``.
|
||||||
|
|
||||||
Using Aggregates in Spreads
|
Aggregates in To-One Spreads
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
All the aggregates inside a :ref:`spread embedded resource <spread_embed>` will be hoisted to the top-level relationship.
|
All the aggregates inside a :ref:`one-to-one or many-to-one spread embedded resource <spread_to_one_embed>` will be hoisted to the top-level relationship.
|
||||||
In other words, it will behave as if the aggregate was done in the top-level relationship itself. For example:
|
In other words, it will behave as if the aggregate was done in the top-level relationship itself. For example:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
@@ -251,3 +251,7 @@ This will take the ``max`` and ``min`` subscription date of every customer and g
|
|||||||
"min": "2016-02-11"
|
"min": "2016-02-11"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Aggregates inside to-many spreads are not supported
|
||||||
|
|||||||
@@ -1150,7 +1150,20 @@ For example, to arrange the films in descending order using the director's last
|
|||||||
Spread embedded resource
|
Spread embedded resource
|
||||||
========================
|
========================
|
||||||
|
|
||||||
On many-to-one and one-to-one relationships, you can "spread" the embedded resource. That is, remove the surrounding JSON object for the embedded resource columns.
|
The ``...`` operator lets you "spread" an embedded resource.
|
||||||
|
That is, it removes the surrounding JSON object for the embedded resource columns.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
The spread operator ``...`` is borrowed from the Javascript `spread syntax <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax>`_.
|
||||||
|
|
||||||
|
.. _spread_to_one_embed:
|
||||||
|
|
||||||
|
Spread To-One relationships
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
This applies to :ref:`one-to-one <one-to-one>` and :ref:`many-to-one <many-to-one>` relationships.
|
||||||
|
Take the following example:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
@@ -1196,6 +1209,107 @@ You can use this to get the columns of a join table in a many-to-many relationsh
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
.. note::
|
.. _spread_to_many_embed:
|
||||||
|
|
||||||
The spread operator ``...`` is borrowed from the Javascript `spread syntax <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax>`_.
|
Spread To-Many relationships
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
The spread columns in :ref:`one-to-many <one-to-many>` or :ref:`many-to-many <many-to-many>` relationships will show the data in arrays.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
# curl -g "http://localhost:3000/directors?select=first_name,...films(film_titles:title,film_years:year)&first_name=like.Quentin*"
|
||||||
|
|
||||||
|
curl --get "http://localhost:3000/directors" \
|
||||||
|
-d "select=first_name,...films(film_titles:title,film_years:year)" \
|
||||||
|
-d "first_name=like.Quentin*"
|
||||||
|
|
||||||
|
.. code-block:: json
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"first_name": "Quentin",
|
||||||
|
"film_titles": [
|
||||||
|
"Pulp Fiction",
|
||||||
|
"Reservoir Dogs"
|
||||||
|
],
|
||||||
|
"film_years": [
|
||||||
|
1994,
|
||||||
|
1992
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Note that there is no ``films`` array of objects.
|
||||||
|
|
||||||
|
By default, the order of the values inside the resulting array is unspecified but `it is safe to assume <https://www.postgresql.org/message-id/15950.1491843689%40sss.pgh.pa.us>`_ that all the columns return the values in the same unspecified order.
|
||||||
|
From the previous result, we can say that "Pulp Fiction" premiered in 1994 and "Reservoir Dogs" in 1992.
|
||||||
|
You can still order all the resulting arrays explicitly. For example, to order by the release year:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
# curl -g "http://localhost:3000/directors?select=first_name,...films(film_titles:title,film_years:year)&first_name=like.Quentin*&films.order=year"
|
||||||
|
|
||||||
|
curl --get "http://localhost:3000/directors" \
|
||||||
|
-d "select=first_name,...films(film_titles:title,film_years:year)" \
|
||||||
|
-d "first_name=like.Quentin*" \
|
||||||
|
-d "films.order=year"
|
||||||
|
|
||||||
|
.. code-block:: json
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"first_name": "Quentin",
|
||||||
|
"film_titles": [
|
||||||
|
"Reservoir Dogs",
|
||||||
|
"Pulp Fiction"
|
||||||
|
],
|
||||||
|
"film_years": [
|
||||||
|
1992,
|
||||||
|
1994
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Nesting Spreads
|
||||||
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
For example, let's nest ``...technical_specs`` (one-to-one) and ``...roles`` (one-to-many) inside ``...films``:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
# curl -g "http://localhost:3000/directors?select=first_name,...films(film_titles:title,film_years:year,...technical_specs(film_runtimes:runtime),...roles(film_characters:character))&first_name=like.Quentin*&films.order=year&films.roles.order=character"
|
||||||
|
|
||||||
|
curl --get "http://localhost:3000/directors" \
|
||||||
|
-d "select=first_name,...films(film_titles:title,film_years:year,...technical_specs(film_runtimes:runtime),...roles(film_characters:character))" \
|
||||||
|
-d "first_name=like.Quentin*" \
|
||||||
|
-d "films.order=year" \
|
||||||
|
-d "films.roles.order=character"
|
||||||
|
|
||||||
|
.. code-block:: json
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"first_name": "Quentin",
|
||||||
|
"film_titles": [
|
||||||
|
"Reservoir Dogs",
|
||||||
|
"Pulp Fiction"
|
||||||
|
],
|
||||||
|
"film_years": [
|
||||||
|
1992,
|
||||||
|
1994
|
||||||
|
],
|
||||||
|
"film_runtimes": [
|
||||||
|
"01:39:00",
|
||||||
|
"02:29:00"
|
||||||
|
]
|
||||||
|
"film_characters": [
|
||||||
|
[ "Mr. Pink", "Mr. White" ],
|
||||||
|
[ "Mia Wallace", "Vincent Vega" ]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
All the elements inside ``films`` are selected in the same order, including both nested resources.
|
||||||
|
For example, we can say that "Reservoir Dogs" premiered in 1992, its runtime is 1:39:00 and it has the following characters: ``[ "Mr. Pink", "Mr. White" ]``.
|
||||||
|
Note that the data inside to-many nested resources can also be ordered (``roles`` by the ``character`` name in our example).
|
||||||
|
|||||||
@@ -233,10 +233,6 @@ Related to the HTTP request elements.
|
|||||||
| | | there is no many-to-one or one-to-one relationship between |
|
| | | there is no many-to-one or one-to-one relationship between |
|
||||||
| PGRST118 | | them. |
|
| PGRST118 | | them. |
|
||||||
+---------------+-------------+-------------------------------------------------------------+
|
+---------------+-------------+-------------------------------------------------------------+
|
||||||
| .. _pgrst119: | 400 | Could not use the spread operator on the related table |
|
|
||||||
| | | because there is no many-to-one or one-to-one relationship |
|
|
||||||
| PGRST119 | | between them. |
|
|
||||||
+---------------+-------------+-------------------------------------------------------------+
|
|
||||||
| .. _pgrst120: | 400 | An embedded resource can only be filtered using the |
|
| .. _pgrst120: | 400 | An embedded resource can only be filtered using the |
|
||||||
| | | ``is.null`` or ``not.is.null`` :ref:`operators <operators>`.|
|
| | | ``is.null`` or ``not.is.null`` :ref:`operators <operators>`.|
|
||||||
| PGRST120 | | |
|
| PGRST120 | | |
|
||||||
@@ -265,6 +261,10 @@ Related to the HTTP request elements.
|
|||||||
| | | accessed. See :ref:`openapi-mode`. |
|
| | | accessed. See :ref:`openapi-mode`. |
|
||||||
| PGRST126 | | |
|
| PGRST126 | | |
|
||||||
+---------------+-------------+-------------------------------------------------------------+
|
+---------------+-------------+-------------------------------------------------------------+
|
||||||
|
| .. _pgrst127: | 400 | The feature specified in the ``details`` field is not |
|
||||||
|
| | | implemented. |
|
||||||
|
| PGRST127 | | |
|
||||||
|
+---------------+-------------+-------------------------------------------------------------+
|
||||||
|
|
||||||
|
|
||||||
.. _pgrst2**:
|
.. _pgrst2**:
|
||||||
|
|||||||
+8
-10
@@ -75,10 +75,10 @@ data ApiRequestError
|
|||||||
| NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
|
| NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
|
||||||
| NoRpc Text Text [Text] MediaType Bool [QualifiedIdentifier] [Routine]
|
| NoRpc Text Text [Text] MediaType Bool [QualifiedIdentifier] [Routine]
|
||||||
| NotEmbedded Text
|
| NotEmbedded Text
|
||||||
|
| NotImplemented Text
|
||||||
| PutLimitNotAllowedError
|
| PutLimitNotAllowedError
|
||||||
| QueryParamError QPError
|
| QueryParamError QPError
|
||||||
| RelatedOrderNotToOne Text Text
|
| RelatedOrderNotToOne Text Text
|
||||||
| SpreadNotToOne Text Text
|
|
||||||
| UnacceptableFilter Text
|
| UnacceptableFilter Text
|
||||||
| UnacceptableSchema [Text]
|
| UnacceptableSchema [Text]
|
||||||
| UnsupportedMethod ByteString
|
| UnsupportedMethod ByteString
|
||||||
@@ -123,10 +123,10 @@ instance PgrstError ApiRequestError where
|
|||||||
status NoRelBetween{} = HTTP.status400
|
status NoRelBetween{} = HTTP.status400
|
||||||
status NoRpc{} = HTTP.status404
|
status NoRpc{} = HTTP.status404
|
||||||
status NotEmbedded{} = HTTP.status400
|
status NotEmbedded{} = HTTP.status400
|
||||||
|
status NotImplemented{} = HTTP.status400
|
||||||
status PutLimitNotAllowedError = HTTP.status400
|
status PutLimitNotAllowedError = HTTP.status400
|
||||||
status QueryParamError{} = HTTP.status400
|
status QueryParamError{} = HTTP.status400
|
||||||
status RelatedOrderNotToOne{} = HTTP.status400
|
status RelatedOrderNotToOne{} = HTTP.status400
|
||||||
status SpreadNotToOne{} = HTTP.status400
|
|
||||||
status UnacceptableFilter{} = HTTP.status400
|
status UnacceptableFilter{} = HTTP.status400
|
||||||
status UnacceptableSchema{} = HTTP.status406
|
status UnacceptableSchema{} = HTTP.status406
|
||||||
status UnsupportedMethod{} = HTTP.status405
|
status UnsupportedMethod{} = HTTP.status405
|
||||||
@@ -212,12 +212,6 @@ instance JSON.ToJSON ApiRequestError where
|
|||||||
(Just $ JSON.String $ "'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship")
|
(Just $ JSON.String $ "'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship")
|
||||||
Nothing
|
Nothing
|
||||||
|
|
||||||
toJSON (SpreadNotToOne origin target) = toJsonPgrstError
|
|
||||||
ApiRequestErrorCode19
|
|
||||||
("A spread operation on '" <> target <> "' is not possible")
|
|
||||||
(Just $ JSON.String $ "'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship")
|
|
||||||
Nothing
|
|
||||||
|
|
||||||
toJSON (UnacceptableFilter target) = toJsonPgrstError
|
toJSON (UnacceptableFilter target) = toJsonPgrstError
|
||||||
ApiRequestErrorCode20
|
ApiRequestErrorCode20
|
||||||
("Bad operator on the '" <> target <> "' embedded resource")
|
("Bad operator on the '" <> target <> "' embedded resource")
|
||||||
@@ -307,6 +301,9 @@ instance JSON.ToJSON ApiRequestError where
|
|||||||
Nothing
|
Nothing
|
||||||
(JSON.String <$> tableNotFoundHint schemaName relName tbls)
|
(JSON.String <$> tableNotFoundHint schemaName relName tbls)
|
||||||
|
|
||||||
|
toJSON (NotImplemented details) = toJsonPgrstError
|
||||||
|
ApiRequestErrorCode27 "Feature not implemented" (Just $ JSON.String details) Nothing
|
||||||
|
|
||||||
-- |
|
-- |
|
||||||
-- If no relationship is found then:
|
-- If no relationship is found then:
|
||||||
--
|
--
|
||||||
@@ -712,7 +709,7 @@ data ErrorCode
|
|||||||
| ApiRequestErrorCode16
|
| ApiRequestErrorCode16
|
||||||
| ApiRequestErrorCode17
|
| ApiRequestErrorCode17
|
||||||
| ApiRequestErrorCode18
|
| ApiRequestErrorCode18
|
||||||
| ApiRequestErrorCode19
|
-- | ApiRequestErrorCode19 -- no longer used (used to be mapped to SpreadNotToOne)
|
||||||
| ApiRequestErrorCode20
|
| ApiRequestErrorCode20
|
||||||
| ApiRequestErrorCode21
|
| ApiRequestErrorCode21
|
||||||
| ApiRequestErrorCode22
|
| ApiRequestErrorCode22
|
||||||
@@ -720,6 +717,7 @@ data ErrorCode
|
|||||||
| ApiRequestErrorCode24
|
| ApiRequestErrorCode24
|
||||||
| ApiRequestErrorCode25
|
| ApiRequestErrorCode25
|
||||||
| ApiRequestErrorCode26
|
| ApiRequestErrorCode26
|
||||||
|
| ApiRequestErrorCode27
|
||||||
-- Schema Cache errors
|
-- Schema Cache errors
|
||||||
| SchemaCacheErrorCode00
|
| SchemaCacheErrorCode00
|
||||||
| SchemaCacheErrorCode01
|
| SchemaCacheErrorCode01
|
||||||
@@ -763,7 +761,6 @@ buildErrorCode code = case code of
|
|||||||
ApiRequestErrorCode16 -> "PGRST116"
|
ApiRequestErrorCode16 -> "PGRST116"
|
||||||
ApiRequestErrorCode17 -> "PGRST117"
|
ApiRequestErrorCode17 -> "PGRST117"
|
||||||
ApiRequestErrorCode18 -> "PGRST118"
|
ApiRequestErrorCode18 -> "PGRST118"
|
||||||
ApiRequestErrorCode19 -> "PGRST119"
|
|
||||||
ApiRequestErrorCode20 -> "PGRST120"
|
ApiRequestErrorCode20 -> "PGRST120"
|
||||||
ApiRequestErrorCode21 -> "PGRST121"
|
ApiRequestErrorCode21 -> "PGRST121"
|
||||||
ApiRequestErrorCode22 -> "PGRST122"
|
ApiRequestErrorCode22 -> "PGRST122"
|
||||||
@@ -771,6 +768,7 @@ buildErrorCode code = case code of
|
|||||||
ApiRequestErrorCode24 -> "PGRST124"
|
ApiRequestErrorCode24 -> "PGRST124"
|
||||||
ApiRequestErrorCode25 -> "PGRST125"
|
ApiRequestErrorCode25 -> "PGRST125"
|
||||||
ApiRequestErrorCode26 -> "PGRST126"
|
ApiRequestErrorCode26 -> "PGRST126"
|
||||||
|
ApiRequestErrorCode27 -> "PGRST127"
|
||||||
|
|
||||||
SchemaCacheErrorCode00 -> "PGRST200"
|
SchemaCacheErrorCode00 -> "PGRST200"
|
||||||
SchemaCacheErrorCode01 -> "PGRST201"
|
SchemaCacheErrorCode01 -> "PGRST201"
|
||||||
|
|||||||
+75
-44
@@ -29,6 +29,7 @@ import qualified Data.HashMap.Strict as HM
|
|||||||
import qualified Data.HashMap.Strict.InsOrd as HMI
|
import qualified Data.HashMap.Strict.InsOrd as HMI
|
||||||
import qualified Data.List as L
|
import qualified Data.List as L
|
||||||
import qualified Data.Set as S
|
import qualified Data.Set as S
|
||||||
|
import qualified Data.Text as T
|
||||||
import qualified PostgREST.SchemaCache.Routine as Routine
|
import qualified PostgREST.SchemaCache.Routine as Routine
|
||||||
|
|
||||||
import Data.Either.Combinators (mapLeft, mapRight)
|
import Data.Either.Combinators (mapLeft, mapRight)
|
||||||
@@ -338,11 +339,11 @@ readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregate
|
|||||||
in
|
in
|
||||||
mapLeft ApiRequestError $
|
mapLeft ApiRequestError $
|
||||||
treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
|
treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
|
||||||
|
addToManyOrderSelects =<<
|
||||||
hoistSpreadAggFunctions =<<
|
hoistSpreadAggFunctions =<<
|
||||||
validateAggFunctions configDbAggregates =<<
|
validateAggFunctions configDbAggregates =<<
|
||||||
addRelSelects =<<
|
addRelSelects =<<
|
||||||
addNullEmbedFilters =<<
|
addNullEmbedFilters =<<
|
||||||
validateSpreadEmbeds =<<
|
|
||||||
addRelatedOrders =<<
|
addRelatedOrders =<<
|
||||||
addAliases =<<
|
addAliases =<<
|
||||||
expandStars ctx =<<
|
expandStars ctx =<<
|
||||||
@@ -358,7 +359,7 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} =
|
|||||||
foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} []
|
foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} []
|
||||||
where
|
where
|
||||||
rootDepth = 0
|
rootDepth = 0
|
||||||
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing False [] rootDepth
|
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing Nothing [] rootDepth
|
||||||
treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree
|
treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree
|
||||||
treeEntry depth (Node si fldForest) (Node q rForest) =
|
treeEntry depth (Node si fldForest) (Node q rForest) =
|
||||||
let nxtDepth = succ depth in
|
let nxtDepth = succ depth in
|
||||||
@@ -371,33 +372,41 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} =
|
|||||||
SpreadRelation{..} ->
|
SpreadRelation{..} ->
|
||||||
Node q $
|
Node q $
|
||||||
foldr (treeEntry nxtDepth)
|
foldr (treeEntry nxtDepth)
|
||||||
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relIsSpread=True} [])
|
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relSpread=Just ToOneSpread} [])
|
||||||
fldForest:rForest
|
fldForest:rForest
|
||||||
SelectField{..} ->
|
SelectField{..} ->
|
||||||
Node q{select=CoercibleSelectField (resolveOutputField ctx{qi=from q} selField) selAggregateFunction selAggregateCast selCast selAlias:select q} rForest
|
Node q{select=CoercibleSelectField (resolveOutputField ctx{qi=from q} selField) selAggregateFunction selAggregateCast selCast selAlias:select q} rForest
|
||||||
|
|
||||||
-- If an alias is explicitly specified, it is always respected. However, an alias may be
|
-- If an alias is explicitly specified, it is always respected. However, an alias may be
|
||||||
-- determined automatically in the case of a select term with a JSON path, or in the case
|
-- determined automatically in these cases:
|
||||||
-- of domain representations.
|
-- * A select term with a JSON path
|
||||||
|
-- * Domain representations
|
||||||
|
-- * Aggregates in spread relationships
|
||||||
addAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
addAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addAliases = Right . fmap addAliasToPlan
|
addAliases = Right . fmap addAliasToPlan
|
||||||
where
|
where
|
||||||
addAliasToPlan rp@ReadPlan{select=sel} = rp{select=map aliasSelectField sel}
|
addAliasToPlan rp@ReadPlan{select=sel, relSpread=spr} = rp{select=map (aliasSelectField $ isJust spr) sel}
|
||||||
|
|
||||||
aliasSelectField :: CoercibleSelectField -> CoercibleSelectField
|
aliasSelectField :: Bool -> CoercibleSelectField -> CoercibleSelectField
|
||||||
aliasSelectField field@CoercibleSelectField{csField=fieldDetails, csAggFunction=aggFun, csAlias=alias}
|
aliasSelectField isSpread field@CoercibleSelectField{csField=fieldDetails, csAggFunction=aggFun, csAlias=alias}
|
||||||
| isJust alias = field
|
| isJust alias = field
|
||||||
| isJust aggFun = fieldAliasForSpreadAgg field
|
| isJust aggFun = fieldAliasForSpreadAgg isSpread field
|
||||||
| isJsonKeyPath fieldDetails, Just key <- lastJsonKey fieldDetails = field { csAlias = Just key }
|
| isJsonKeyPath fieldDetails, Just key <- lastJsonKey fieldDetails = field { csAlias = Just key }
|
||||||
| isTransformPath fieldDetails = field { csAlias = Just (cfName fieldDetails) }
|
| isTransformPath fieldDetails = field { csAlias = Just (cfName fieldDetails) }
|
||||||
| otherwise = field
|
| otherwise = field
|
||||||
|
|
||||||
-- A request like: `/top_table?select=...middle_table(...nested_table(count()))` will `SELECT` the full row instead of `*`,
|
-- Spread relationships with non-aliased aggregates can cause problems when selecting the fields in the top level resource.
|
||||||
-- because doing a `COUNT(*)` in `top_table` would not return the desired results.
|
-- The top level won't know the name of the field in this case:
|
||||||
-- So we use the "count" alias if none is present since the field name won't be selected.
|
-- A nested to-one spread like `/top_table?select=...middle_table(...nested_table(count()))`
|
||||||
fieldAliasForSpreadAgg field
|
-- will do a `SELECT nested_table` instead of `SELECT *`, because doing a `COUNT(*)` in `top_table`
|
||||||
| cfFullRow (csField field) = field { csAlias = Just "count" }
|
-- would not return the desired results.
|
||||||
| otherwise = field
|
--
|
||||||
|
-- That's why we need to use the aggregate name as an alias (e.g. COUNT(...) AS "count").
|
||||||
|
-- Since PostgreSQL labels the columns with the aggregate name, it shouldn't be a problem to
|
||||||
|
-- apply the aliases to all the aggregates regardless if the previous conditions are met.
|
||||||
|
fieldAliasForSpreadAgg True field@CoercibleSelectField{csAggFunction=Just agg} =
|
||||||
|
field { csAlias = Just (T.toLower $ show agg) }
|
||||||
|
fieldAliasForSpreadAgg _ field = field
|
||||||
|
|
||||||
isJsonKeyPath CoercibleField{cfJsonPath=(_: _)} = True
|
isJsonKeyPath CoercibleField{cfJsonPath=(_: _)} = True
|
||||||
isJsonKeyPath _ = False
|
isJsonKeyPath _ = False
|
||||||
@@ -423,13 +432,14 @@ knownColumnsInContext ResolverContext{..} =
|
|||||||
-- | Expand "select *" into explicit field names of the table in the following situations:
|
-- | Expand "select *" into explicit field names of the table in the following situations:
|
||||||
-- * When there are data representations present.
|
-- * When there are data representations present.
|
||||||
-- * When there is an aggregate function in a given ReadPlan or its parent.
|
-- * When there is an aggregate function in a given ReadPlan or its parent.
|
||||||
|
-- * When the ReadPlan is a to-many spread relationship
|
||||||
expandStars :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
expandStars :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
|
expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
|
||||||
where
|
where
|
||||||
expandStarsForReadPlan :: Bool -> ReadPlanTree -> ReadPlanTree
|
expandStarsForReadPlan :: Bool -> ReadPlanTree -> ReadPlanTree
|
||||||
expandStarsForReadPlan hasAgg (Node rp@ReadPlan{select, from=fromQI, fromAlias=alias} children) =
|
expandStarsForReadPlan hasAgg (Node rp@ReadPlan{select, from=fromQI, fromAlias=alias, relSpread=spread} children) =
|
||||||
let
|
let
|
||||||
newHasAgg = hasAgg || any (isJust . csAggFunction) select
|
newHasAgg = hasAgg || any (isJust . csAggFunction) select || case spread of Just ToManySpread{} -> True; _ -> False
|
||||||
newCtx = adjustContext ctx fromQI alias
|
newCtx = adjustContext ctx fromQI alias
|
||||||
newRPlan = expandStarsForTable newCtx newHasAgg rp
|
newRPlan = expandStarsForTable newCtx newHasAgg rp
|
||||||
in Node newRPlan (map (expandStarsForReadPlan newHasAgg) children)
|
in Node newRPlan (map (expandStarsForReadPlan newHasAgg) children)
|
||||||
@@ -442,18 +452,18 @@ expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
|
|||||||
adjustContext context fromQI _ = context{qi=fromQI}
|
adjustContext context fromQI _ = context{qi=fromQI}
|
||||||
|
|
||||||
expandStarsForTable :: ResolverContext -> Bool -> ReadPlan -> ReadPlan
|
expandStarsForTable :: ResolverContext -> Bool -> ReadPlan -> ReadPlan
|
||||||
expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields, relIsSpread=isSpread}
|
expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields, relSpread=spread}
|
||||||
-- We expand if either of the below are true:
|
-- We expand if either of the below are true:
|
||||||
-- * We have a '*' select AND there is an aggregate function in this ReadPlan's sub-tree.
|
-- * We have a '*' select AND there is an aggregate function in this ReadPlan's sub-tree.
|
||||||
-- * We have a '*' select AND the target table has at least one data representation.
|
-- * We have a '*' select AND the target table has at least one data representation.
|
||||||
-- We ignore '*' selects that have an aggregate function attached, unless it's a `COUNT(*)` for a Spread Embed,
|
-- We ignore '*' selects that have an aggregate function attached, unless it's a `COUNT(*)` for a Spread Embed,
|
||||||
-- we tag it as "full row" in that case.
|
-- we tag it as "full row" in that case.
|
||||||
| hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField isSpread knownColumns) selectFields}
|
| hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField (isJust spread) knownColumns) selectFields}
|
||||||
| otherwise = rp
|
| otherwise = rp
|
||||||
where
|
where
|
||||||
hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields
|
hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields
|
||||||
filteredSelectFields = filter (shouldExpandOrTag . csAggFunction) selectFields
|
filteredSelectFields = filter (shouldExpandOrTag . csAggFunction) selectFields
|
||||||
shouldExpandOrTag aggFunc = isNothing aggFunc || (isSpread && aggFunc == Just Count)
|
shouldExpandOrTag aggFunc = isNothing aggFunc || (isJust spread && aggFunc == Just Count)
|
||||||
hasDataRepresentation = any hasOutputRep knownColumns
|
hasDataRepresentation = any hasOutputRep knownColumns
|
||||||
knownColumns = knownColumnsInContext ctx
|
knownColumns = knownColumnsInContext ctx
|
||||||
|
|
||||||
@@ -478,20 +488,21 @@ treeRestrictRange maxRows _ request = pure $ nodeRestrictRange maxRows <$> reque
|
|||||||
-- add relationships to the nodes of the tree by traversing the forest while keeping track of the parentNode(https://stackoverflow.com/questions/22721064/get-the-parent-of-a-node-in-data-tree-haskell#comment34627048_22721064)
|
-- add relationships to the nodes of the tree by traversing the forest while keeping track of the parentNode(https://stackoverflow.com/questions/22721064/get-the-parent-of-a-node-in-data-tree-haskell#comment34627048_22721064)
|
||||||
-- also adds aliasing
|
-- also adds aliasing
|
||||||
addRels :: Schema -> Action -> RelationshipsMap -> Maybe ReadPlanTree -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
addRels :: Schema -> Action -> RelationshipsMap -> Maybe ReadPlanTree -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,relAlias,depth} forest) =
|
addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,relAlias,relSpread,depth} forest) =
|
||||||
case parentNode of
|
case parentNode of
|
||||||
Just (Node ReadPlan{from=parentNodeQi, fromAlias=parentAlias} _) ->
|
Just (Node ReadPlan{from=parentNodeQi, fromAlias=parentAlias} _) ->
|
||||||
let
|
let
|
||||||
newReadPlan = (\r ->
|
newReadPlan = (\r ->
|
||||||
let newAlias = Just (qiName (relForeignTable r) <> "_" <> show depth)
|
let newAlias = Just (qiName (relForeignTable r) <> "_" <> show depth)
|
||||||
aggAlias = qiName (relTable r) <> "_" <> fromMaybe relName relAlias <> "_" <> show depth in
|
aggAlias = qiName (relTable r) <> "_" <> fromMaybe relName relAlias <> "_" <> show depth
|
||||||
|
updSpread = if isJust relSpread && not (relIsToOne r) then Just $ ToManySpread [] [] else relSpread in
|
||||||
case r of
|
case r of
|
||||||
Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing
|
Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing
|
||||||
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r}
|
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r, relSpread=updSpread}
|
||||||
ComputedRelationship{} ->
|
ComputedRelationship{} ->
|
||||||
rPlan{from=relForeignTable r, relToParent=Just r{relTableAlias=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias}
|
rPlan{from=relForeignTable r, relToParent=Just r{relTableAlias=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias, relSpread=updSpread}
|
||||||
_ ->
|
_ ->
|
||||||
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, fromAlias=newAlias, relJoinConds=getJoinConditions newAlias parentAlias r}
|
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, fromAlias=newAlias, relJoinConds=getJoinConditions newAlias parentAlias r, relSpread=updSpread}
|
||||||
) <$> rel
|
) <$> rel
|
||||||
origin = if depth == 1 -- Only on depth 1 we check if the root(depth 0) has an alias so the sourceCTEName alias can be found as a relationship
|
origin = if depth == 1 -- Only on depth 1 we check if the root(depth 0) has an alias so the sourceCTEName alias can be found as a relationship
|
||||||
then fromMaybe (qiName parentNodeQi) parentAlias
|
then fromMaybe (qiName parentNodeQi) parentAlias
|
||||||
@@ -632,9 +643,9 @@ addRelSelects node@(Node rp forest)
|
|||||||
in Right $ Node rp { relSelect = newRelSelects } newForest
|
in Right $ Node rp { relSelect = newRelSelects } newForest
|
||||||
|
|
||||||
generateRelSelectField :: ReadPlanTree -> Maybe RelSelectField
|
generateRelSelectField :: ReadPlanTree -> Maybe RelSelectField
|
||||||
generateRelSelectField (Node rp@ReadPlan{relToParent=Just _, relAggAlias, relIsSpread = True} _) =
|
generateRelSelectField (Node rp@ReadPlan{relToParent=Just _, relAggAlias, relSpread = Just _} _) =
|
||||||
Just $ Spread { rsSpreadSel = generateSpreadSelectFields rp, rsAggAlias = relAggAlias }
|
Just $ Spread { rsSpreadSel = generateSpreadSelectFields rp, rsAggAlias = relAggAlias }
|
||||||
generateRelSelectField (Node ReadPlan{relToParent=Just rel, select, relName, relAlias, relAggAlias, relIsSpread = False} forest) =
|
generateRelSelectField (Node ReadPlan{relToParent=Just rel, select, relName, relAlias, relAggAlias, relSpread = Nothing} forest) =
|
||||||
Just $ JsonEmbed { rsEmbedMode, rsSelName, rsAggAlias = relAggAlias, rsEmptyEmbed }
|
Just $ JsonEmbed { rsEmbedMode, rsSelName, rsAggAlias = relAggAlias, rsEmptyEmbed }
|
||||||
where
|
where
|
||||||
rsSelName = fromMaybe relName relAlias
|
rsSelName = fromMaybe relName relAlias
|
||||||
@@ -663,7 +674,7 @@ generateSpreadSelectFields ReadPlan{select, relSelect} =
|
|||||||
relSelectToSpread (Spread{rsSpreadSel}) =
|
relSelectToSpread (Spread{rsSpreadSel}) =
|
||||||
rsSpreadSel
|
rsSpreadSel
|
||||||
|
|
||||||
-- When aggregates are present in a ReadPlan that will be spread, we "hoist"
|
-- When aggregates are present in a ReadPlan with a to-one spread, we "hoist"
|
||||||
-- to the highest level possible so that their semantics make sense. For instance,
|
-- to the highest level possible so that their semantics make sense. For instance,
|
||||||
-- imagine the user performs the following request:
|
-- imagine the user performs the following request:
|
||||||
-- `GET /projects?select=client_id,...project_invoices(invoice_total.sum())`
|
-- `GET /projects?select=client_id,...project_invoices(invoice_total.sum())`
|
||||||
@@ -684,28 +695,31 @@ generateSpreadSelectFields ReadPlan{select, relSelect} =
|
|||||||
--
|
--
|
||||||
-- The second tuple contains the aggregate function to be applied, the cast, and
|
-- The second tuple contains the aggregate function to be applied, the cast, and
|
||||||
-- the alias, if it was supplied by the user or otherwise determined.
|
-- the alias, if it was supplied by the user or otherwise determined.
|
||||||
|
--
|
||||||
|
-- No hoisting is done for to-many spreads
|
||||||
type HoistedAgg = ((Alias, FieldName), (AggregateFunction, Maybe Cast, Maybe Alias))
|
type HoistedAgg = ((Alias, FieldName), (AggregateFunction, Maybe Cast, Maybe Alias))
|
||||||
|
|
||||||
hoistSpreadAggFunctions :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
hoistSpreadAggFunctions :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
hoistSpreadAggFunctions tree = Right $ fst $ applySpreadAggHoistingToNode tree
|
hoistSpreadAggFunctions tree = Right $ fst $ applySpreadAggHoistingToNode tree
|
||||||
|
|
||||||
applySpreadAggHoistingToNode :: ReadPlanTree -> (ReadPlanTree, [HoistedAgg])
|
applySpreadAggHoistingToNode :: ReadPlanTree -> (ReadPlanTree, [HoistedAgg])
|
||||||
applySpreadAggHoistingToNode (Node rp@ReadPlan{relAggAlias, relToParent, relIsSpread} children) =
|
applySpreadAggHoistingToNode (Node rp@ReadPlan{relAggAlias, relToParent, relSpread} children) =
|
||||||
let (newChildren, childAggLists) = unzip $ map applySpreadAggHoistingToNode children
|
let (newChildren, childAggLists) = unzip $ map applySpreadAggHoistingToNode children
|
||||||
allChildAggLists = concat childAggLists
|
allChildAggLists = concat childAggLists
|
||||||
(newSelects, aggList) = if depth rp == 0 || (isJust relToParent && not relIsSpread)
|
isToOneSpread = relSpread == Just ToOneSpread
|
||||||
|
(newSelects, aggList) = if depth rp == 0 || (isJust relToParent && not isToOneSpread)
|
||||||
then (select rp, [])
|
then (select rp, [])
|
||||||
else hoistFromSelectFields relAggAlias (select rp)
|
else hoistFromSelectFields relAggAlias (select rp)
|
||||||
|
|
||||||
-- If the current `ReadPlan` is a spread rel and it has aggregates hoisted from
|
-- If the current `ReadPlan` is a to-one spread rel and it has aggregates hoisted from
|
||||||
-- child relationships, then it must hoist those aggregates to its parent rel.
|
-- child relationships, then it must hoist those aggregates to its parent rel.
|
||||||
-- So we update them with the current `relAggAlias`.
|
-- So we update them with the current `relAggAlias`.
|
||||||
hoistAgg ((_, fieldName), hoistFunc) = ((relAggAlias, fieldName), hoistFunc)
|
hoistAgg ((_, fieldName), hoistFunc) = ((relAggAlias, fieldName), hoistFunc)
|
||||||
hoistedAggList = if relIsSpread
|
hoistedAggList = if isToOneSpread
|
||||||
then aggList ++ map hoistAgg allChildAggLists
|
then aggList ++ map hoistAgg allChildAggLists
|
||||||
else aggList
|
else aggList
|
||||||
|
|
||||||
newRelSelects = if null children || relIsSpread
|
newRelSelects = if null children || isToOneSpread
|
||||||
then relSelect rp
|
then relSelect rp
|
||||||
else map (hoistIntoRelSelectFields allChildAggLists) $ relSelect rp
|
else map (hoistIntoRelSelectFields allChildAggLists) $ relSelect rp
|
||||||
in (Node rp { select = newSelects, relSelect = newRelSelects } newChildren, hoistedAggList)
|
in (Node rp { select = newSelects, relSelect = newRelSelects } newChildren, hoistedAggList)
|
||||||
@@ -743,6 +757,32 @@ hoistIntoRelSelectFields aggList r@(Spread {rsSpreadSel = spreadSelects, rsAggAl
|
|||||||
Nothing -> s
|
Nothing -> s
|
||||||
hoistIntoRelSelectFields _ r = r
|
hoistIntoRelSelectFields _ r = r
|
||||||
|
|
||||||
|
-- | Handle ordering in a To-Many Spread Relationship
|
||||||
|
-- * It removes the ordering done in the ReadPlan and moves it to the SpreadType.
|
||||||
|
-- We also select the ordering columns and alias them to avoid collisions. This is because it would be impossible
|
||||||
|
-- to order once it's aggregated if it's not selected in the inner query beforehand.
|
||||||
|
addToManyOrderSelects :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
|
addToManyOrderSelects (Node rp@ReadPlan{order, select, relAggAlias, relSelect, relSpread = Just ToManySpread {}} forest)
|
||||||
|
| anyAggSel || anyAggRelSel = Left $ NotImplemented "Aggregates are not implemented for one-to-many or many-to-many spreads."
|
||||||
|
| otherwise = Node rp { order = [], relSpread = newRelSpread } <$> addToManyOrderSelects `traverse` forest
|
||||||
|
where
|
||||||
|
newRelSpread = Just ToManySpread { stExtraSelect = addSprExtraSelects, stOrder = addSprOrder}
|
||||||
|
anyAggSel = any (isJust . csAggFunction) select
|
||||||
|
anyAggRelSel = any (\case Spread sels _ -> any (isJust . ssSelAggFunction) sels; _ -> False) relSelect
|
||||||
|
(addSprExtraSelects, addSprOrder) = unzip $ zipWith ordToExtraSelsAndSprOrds [1..] order
|
||||||
|
ordToExtraSelsAndSprOrds i = \case
|
||||||
|
CoercibleOrderTerm fld dir ordr -> (
|
||||||
|
(Nothing, CoercibleSelectField fld Nothing Nothing Nothing (Just $ selOrdAlias (cfName fld) i)),
|
||||||
|
CoercibleOrderTerm (unknownField (selOrdAlias (cfName fld) i) []) dir ordr
|
||||||
|
)
|
||||||
|
CoercibleOrderRelationTerm rel (fld,jp) dir ordr -> (
|
||||||
|
(Just rel, CoercibleSelectField (unknownField fld jp) Nothing Nothing Nothing (Just $ selOrdAlias fld i)),
|
||||||
|
CoercibleOrderTerm (unknownField (selOrdAlias fld i) []) dir ordr
|
||||||
|
)
|
||||||
|
selOrdAlias :: Alias -> Integer -> Alias
|
||||||
|
selOrdAlias name i = relAggAlias <> "_" <> name <> "_" <> show i -- add index to avoid collisions in aliases
|
||||||
|
addToManyOrderSelects (Node rp forest) = Node rp <$> addToManyOrderSelects `traverse` forest
|
||||||
|
|
||||||
validateAggFunctions :: Bool -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
validateAggFunctions :: Bool -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest)
|
validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest)
|
||||||
| not aggFunctionsAllowed && any (isJust . csAggFunction) select = Left AggregatesNotAllowed
|
| not aggFunctionsAllowed && any (isJust . csAggFunction) select = Left AggregatesNotAllowed
|
||||||
@@ -825,7 +865,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
|
|||||||
-- relName = "projects",
|
-- relName = "projects",
|
||||||
-- relToParent = Nothing,
|
-- relToParent = Nothing,
|
||||||
-- relJoinConds = [],
|
-- relJoinConds = [],
|
||||||
-- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relIsSpread = False, depth = 1,
|
-- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relSpread = Nothing, depth = 1,
|
||||||
-- relSelect = []
|
-- relSelect = []
|
||||||
-- },
|
-- },
|
||||||
-- subForest = []
|
-- subForest = []
|
||||||
@@ -851,7 +891,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
|
|||||||
-- )
|
-- )
|
||||||
-- ],
|
-- ],
|
||||||
-- order = [], range_ = fullRange, relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing,
|
-- order = [], range_ = fullRange, relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing,
|
||||||
-- relJoinType = Nothing, relIsSpread = False, depth = 0,
|
-- relJoinType = Nothing, relSpread = Nothing, depth = 0,
|
||||||
-- relSelect = []
|
-- relSelect = []
|
||||||
-- },
|
-- },
|
||||||
-- subForest = subForst
|
-- subForest = subForst
|
||||||
@@ -916,15 +956,6 @@ resolveLogicTree ctx (Expr b op lts) = CoercibleExpr b op (map (resolveLogicTree
|
|||||||
resolveFilter :: ResolverContext -> Filter -> CoercibleFilter
|
resolveFilter :: ResolverContext -> Filter -> CoercibleFilter
|
||||||
resolveFilter ctx (Filter fld opExpr) = CoercibleFilter{field=resolveQueryInputField ctx fld opExpr, opExpr=opExpr}
|
resolveFilter ctx (Filter fld opExpr) = CoercibleFilter{field=resolveQueryInputField ctx fld opExpr, opExpr=opExpr}
|
||||||
|
|
||||||
-- Validates that spread embeds are only done on to-one relationships
|
|
||||||
validateSpreadEmbeds :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
|
||||||
validateSpreadEmbeds (Node rp@ReadPlan{relToParent=Nothing} forest) = Node rp <$> validateSpreadEmbeds `traverse` forest
|
|
||||||
validateSpreadEmbeds (Node rp@ReadPlan{relIsSpread,relToParent=Just rel,relName} forest) = do
|
|
||||||
validRP <- if relIsSpread && not (relIsToOne rel)
|
|
||||||
then Left $ SpreadNotToOne (qiName $ relTable rel) relName -- TODO using relTable is not entirely right because ReadPlan might have an alias, need to store the parent alias on ReadPlan
|
|
||||||
else Right rp
|
|
||||||
Node validRP <$> validateSpreadEmbeds `traverse` forest
|
|
||||||
|
|
||||||
-- Find a Node of the Tree and apply a function to it
|
-- Find a Node of the Tree and apply a function to it
|
||||||
updateNode :: (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
updateNode :: (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||||
updateNode f ([], a) rr = f a <$> rr
|
updateNode f ([], a) rr = f a <$> rr
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ module PostgREST.Plan.ReadPlan
|
|||||||
( ReadPlanTree
|
( ReadPlanTree
|
||||||
, ReadPlan(..)
|
, ReadPlan(..)
|
||||||
, JoinCondition(..)
|
, JoinCondition(..)
|
||||||
|
, SpreadType(..)
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.Tree (Tree (..))
|
import Data.Tree (Tree (..))
|
||||||
@@ -11,7 +12,8 @@ import PostgREST.ApiRequest.Types (Alias, Depth, Hint,
|
|||||||
import PostgREST.Plan.Types (CoercibleLogicTree,
|
import PostgREST.Plan.Types (CoercibleLogicTree,
|
||||||
CoercibleOrderTerm,
|
CoercibleOrderTerm,
|
||||||
CoercibleSelectField (..),
|
CoercibleSelectField (..),
|
||||||
RelSelectField (..))
|
RelSelectField (..),
|
||||||
|
SpreadType (..))
|
||||||
import PostgREST.RangeQuery (NonnegRange)
|
import PostgREST.RangeQuery (NonnegRange)
|
||||||
import PostgREST.SchemaCache.Identifiers (FieldName,
|
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||||
QualifiedIdentifier)
|
QualifiedIdentifier)
|
||||||
@@ -42,7 +44,7 @@ data ReadPlan = ReadPlan
|
|||||||
, relAggAlias :: Alias
|
, relAggAlias :: Alias
|
||||||
, relHint :: Maybe Hint
|
, relHint :: Maybe Hint
|
||||||
, relJoinType :: Maybe JoinType
|
, relJoinType :: Maybe JoinType
|
||||||
, relIsSpread :: Bool
|
, relSpread :: Maybe SpreadType
|
||||||
, relSelect :: [RelSelectField]
|
, relSelect :: [RelSelectField]
|
||||||
, depth :: Depth
|
, depth :: Depth
|
||||||
-- ^ used for aliasing
|
-- ^ used for aliasing
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ module PostgREST.Plan.Types
|
|||||||
, RelSelectField(..)
|
, RelSelectField(..)
|
||||||
, RelJsonEmbedMode(..)
|
, RelJsonEmbedMode(..)
|
||||||
, SpreadSelectField(..)
|
, SpreadSelectField(..)
|
||||||
|
, SpreadType(..)
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import PostgREST.ApiRequest.Types (AggregateFunction, Alias, Cast,
|
import PostgREST.ApiRequest.Types (AggregateFunction, Alias, Cast,
|
||||||
@@ -111,3 +112,11 @@ data SpreadSelectField =
|
|||||||
, ssSelAlias :: Maybe Alias
|
, ssSelAlias :: Maybe Alias
|
||||||
}
|
}
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
data SpreadType
|
||||||
|
= ToOneSpread
|
||||||
|
| ToManySpread
|
||||||
|
{ stExtraSelect :: [(Maybe FieldName, CoercibleSelectField)]
|
||||||
|
, stOrder :: [CoercibleOrderTerm]
|
||||||
|
}
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ import PostgREST.Query.SqlFragment
|
|||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
readPlanToQuery :: ReadPlanTree -> SQL.Snippet
|
readPlanToQuery :: ReadPlanTree -> SQL.Snippet
|
||||||
readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds, relSelect} forest) =
|
readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds, relSelect, relSpread} forest) =
|
||||||
"SELECT " <>
|
"SELECT " <>
|
||||||
intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ joinsSelects) <>
|
intercalateSnippet ", " (selects ++ sprExtraSelects ++ joinsSelects) <>
|
||||||
fromFrag <>
|
fromFrag <>
|
||||||
intercalateSnippet " " joins <>
|
intercalateSnippet " " joins <>
|
||||||
(if null logicForest && null relJoinConds
|
(if null logicForest && null relJoinConds
|
||||||
@@ -61,7 +61,11 @@ readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicFor
|
|||||||
-- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage
|
-- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage
|
||||||
defSelect = [CoercibleSelectField (unknownField "*" []) Nothing Nothing Nothing Nothing]
|
defSelect = [CoercibleSelectField (unknownField "*" []) Nothing Nothing Nothing Nothing]
|
||||||
joins = getJoins node
|
joins = getJoins node
|
||||||
|
selects = pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)
|
||||||
joinsSelects = getJoinSelects node
|
joinsSelects = getJoinSelects node
|
||||||
|
sprExtraSelects = case relSpread of
|
||||||
|
Just (ToManySpread sels _) -> (\s -> pgFmtSelectItem (maybe qi (QualifiedIdentifier "") $ fst s) $ snd s) <$> sels
|
||||||
|
_ -> mempty
|
||||||
|
|
||||||
getJoinSelects :: ReadPlanTree -> [SQL.Snippet]
|
getJoinSelects :: ReadPlanTree -> [SQL.Snippet]
|
||||||
getJoinSelects (Node ReadPlan{relSelect} _) =
|
getJoinSelects (Node ReadPlan{relSelect} _) =
|
||||||
@@ -91,23 +95,28 @@ getJoins (Node ReadPlan{relSelect} forest) =
|
|||||||
) relSelect
|
) relSelect
|
||||||
|
|
||||||
getJoin :: RelSelectField -> ReadPlanTree -> SQL.Snippet
|
getJoin :: RelSelectField -> ReadPlanTree -> SQL.Snippet
|
||||||
getJoin fld node@(Node ReadPlan{relJoinType} _) =
|
getJoin fld node@(Node ReadPlan{relJoinType, relSpread} _) =
|
||||||
let
|
let
|
||||||
correlatedSubquery sub al cond =
|
correlatedSubquery sub al cond =
|
||||||
" " <> (if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond
|
" " <> (if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond
|
||||||
subquery = readPlanToQuery node
|
subquery = readPlanToQuery node
|
||||||
aggAlias = pgFmtIdent $ rsAggAlias fld
|
aggAlias = pgFmtIdent $ rsAggAlias fld
|
||||||
|
selectSubqAgg = "SELECT json_agg(" <> aggAlias <> ")::jsonb AS " <> aggAlias
|
||||||
|
fromSubqAgg = " FROM (" <> subquery <> " ) AS " <> aggAlias
|
||||||
|
joinCondition = if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE"
|
||||||
in
|
in
|
||||||
case fld of
|
case fld of
|
||||||
JsonEmbed{rsEmbedMode = JsonObject} ->
|
JsonEmbed{rsEmbedMode = JsonObject} ->
|
||||||
correlatedSubquery subquery aggAlias "TRUE"
|
correlatedSubquery subquery aggAlias "TRUE"
|
||||||
Spread{} ->
|
Spread{rsSpreadSel, rsAggAlias} ->
|
||||||
correlatedSubquery subquery aggAlias "TRUE"
|
case relSpread of
|
||||||
|
Just (ToManySpread _ sprOrder) ->
|
||||||
|
let selSpread = selectSubqAgg <> (if null rsSpreadSel then mempty else ", ") <> intercalateSnippet ", " (pgFmtSpreadJoinSelectItem rsAggAlias sprOrder <$> rsSpreadSel)
|
||||||
|
in correlatedSubquery (selSpread <> fromSubqAgg) aggAlias joinCondition
|
||||||
|
_ ->
|
||||||
|
correlatedSubquery subquery aggAlias "TRUE"
|
||||||
JsonEmbed{rsEmbedMode = JsonArray} ->
|
JsonEmbed{rsEmbedMode = JsonArray} ->
|
||||||
let
|
correlatedSubquery (selectSubqAgg <> fromSubqAgg) aggAlias joinCondition
|
||||||
subq = "SELECT json_agg(" <> aggAlias <> ")::jsonb AS " <> aggAlias <> " FROM (" <> subquery <> " ) AS " <> aggAlias
|
|
||||||
condition = if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE"
|
|
||||||
in correlatedSubquery subq aggAlias condition
|
|
||||||
|
|
||||||
mutatePlanToQuery :: MutatePlan -> SQL.Snippet
|
mutatePlanToQuery :: MutatePlan -> SQL.Snippet
|
||||||
mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings _ applyDefaults) =
|
mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings _ applyDefaults) =
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ module PostgREST.Query.SqlFragment
|
|||||||
, pgFmtOrderTerm
|
, pgFmtOrderTerm
|
||||||
, pgFmtSelectItem
|
, pgFmtSelectItem
|
||||||
, pgFmtSpreadSelectItem
|
, pgFmtSpreadSelectItem
|
||||||
|
, pgFmtSpreadJoinSelectItem
|
||||||
, fromJsonBodyF
|
, fromJsonBodyF
|
||||||
, responseHeadersF
|
, responseHeadersF
|
||||||
, responseStatusF
|
, responseStatusF
|
||||||
@@ -257,7 +258,7 @@ pgFmtField table cf = case cfToTsVector cf of
|
|||||||
_ -> fmtFld
|
_ -> fmtFld
|
||||||
where
|
where
|
||||||
fmtFld = case cf of
|
fmtFld = case cf of
|
||||||
CoercibleField{cfFullRow=True} -> fromQi table
|
CoercibleField{cfFullRow=True} -> pgFmtIdent (qiName table)
|
||||||
CoercibleField{cfName=fn, cfJsonPath=[]} -> pgFmtColumn table fn
|
CoercibleField{cfName=fn, cfJsonPath=[]} -> pgFmtColumn table fn
|
||||||
CoercibleField{cfName=fn, cfToJson=doToJson, cfJsonPath=jp} | doToJson -> "to_jsonb(" <> pgFmtColumn table fn <> ")" <> pgFmtJsonPath jp
|
CoercibleField{cfName=fn, cfToJson=doToJson, cfJsonPath=jp} | doToJson -> "to_jsonb(" <> pgFmtColumn table fn <> ")" <> pgFmtJsonPath jp
|
||||||
| otherwise -> pgFmtColumn table fn <> pgFmtJsonPath jp
|
| otherwise -> pgFmtColumn table fn <> pgFmtJsonPath jp
|
||||||
@@ -278,11 +279,7 @@ pgFmtSelectItem table CoercibleSelectField{csField=fld, csAggFunction=agg, csAgg
|
|||||||
|
|
||||||
pgFmtSpreadSelectItem :: Alias -> SpreadSelectField -> SQL.Snippet
|
pgFmtSpreadSelectItem :: Alias -> SpreadSelectField -> SQL.Snippet
|
||||||
pgFmtSpreadSelectItem aggAlias SpreadSelectField{ssSelName, ssSelAggFunction, ssSelAggCast, ssSelAlias} =
|
pgFmtSpreadSelectItem aggAlias SpreadSelectField{ssSelName, ssSelAggFunction, ssSelAggCast, ssSelAlias} =
|
||||||
pgFmtApplyAggregate ssSelAggFunction ssSelAggCast fullSelName <> pgFmtAs ssSelAlias
|
pgFmtApplyAggregate ssSelAggFunction ssSelAggCast (pgFmtFullSelName aggAlias ssSelName) <> pgFmtAs ssSelAlias
|
||||||
where
|
|
||||||
fullSelName = case ssSelName of
|
|
||||||
"*" -> pgFmtIdent aggAlias <> ".*"
|
|
||||||
_ -> pgFmtIdent aggAlias <> "." <> pgFmtIdent ssSelName
|
|
||||||
|
|
||||||
pgFmtApplyAggregate :: Maybe AggregateFunction -> Maybe Cast -> SQL.Snippet -> SQL.Snippet
|
pgFmtApplyAggregate :: Maybe AggregateFunction -> Maybe Cast -> SQL.Snippet -> SQL.Snippet
|
||||||
pgFmtApplyAggregate Nothing _ snippet = snippet
|
pgFmtApplyAggregate Nothing _ snippet = snippet
|
||||||
@@ -294,6 +291,14 @@ pgFmtApplyAggregate (Just agg) aggCast snippet =
|
|||||||
convertAggFunction = SQL.sql . BS.map toUpper . BS.pack . show
|
convertAggFunction = SQL.sql . BS.map toUpper . BS.pack . show
|
||||||
aggregatedSnippet = convertAggFunction agg <> "(" <> snippet <> ")"
|
aggregatedSnippet = convertAggFunction agg <> "(" <> snippet <> ")"
|
||||||
|
|
||||||
|
pgFmtSpreadJoinSelectItem :: Alias -> [CoercibleOrderTerm] -> SpreadSelectField -> SQL.Snippet
|
||||||
|
pgFmtSpreadJoinSelectItem aggAlias order SpreadSelectField{ssSelName, ssSelAlias} =
|
||||||
|
"COALESCE(json_agg(" <> fmtField <> " " <> fmtOrder <> "),'[]')::jsonb" <> " AS " <> fmtAlias
|
||||||
|
where
|
||||||
|
fmtField = pgFmtFullSelName aggAlias ssSelName
|
||||||
|
fmtOrder = orderF (QualifiedIdentifier "" aggAlias) order
|
||||||
|
fmtAlias = pgFmtIdent (fromMaybe ssSelName ssSelAlias)
|
||||||
|
|
||||||
pgFmtApplyCast :: Maybe Cast -> SQL.Snippet -> SQL.Snippet
|
pgFmtApplyCast :: Maybe Cast -> SQL.Snippet -> SQL.Snippet
|
||||||
pgFmtApplyCast Nothing snippet = snippet
|
pgFmtApplyCast Nothing snippet = snippet
|
||||||
-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc.
|
-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc.
|
||||||
@@ -301,6 +306,11 @@ pgFmtApplyCast Nothing snippet = snippet
|
|||||||
-- Not quoting should be fine, we validate the input on Parsers.
|
-- Not quoting should be fine, we validate the input on Parsers.
|
||||||
pgFmtApplyCast (Just cast) snippet = "CAST( " <> snippet <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )"
|
pgFmtApplyCast (Just cast) snippet = "CAST( " <> snippet <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )"
|
||||||
|
|
||||||
|
pgFmtFullSelName :: Alias -> FieldName -> SQL.Snippet
|
||||||
|
pgFmtFullSelName aggAlias fieldName = case fieldName of
|
||||||
|
"*" -> pgFmtIdent aggAlias <> ".*"
|
||||||
|
_ -> pgFmtIdent aggAlias <> "." <> pgFmtIdent fieldName
|
||||||
|
|
||||||
-- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body
|
-- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body
|
||||||
fromJsonBodyF :: Maybe LBS.ByteString -> [CoercibleField] -> Bool -> Bool -> Bool -> SQL.Snippet
|
fromJsonBodyF :: Maybe LBS.ByteString -> [CoercibleField] -> Bool -> Bool -> Bool -> SQL.Snippet
|
||||||
fromJsonBodyF body fields includeSelect includeLimitOne includeDefaults =
|
fromJsonBodyF body fields includeSelect includeLimitOne includeDefaults =
|
||||||
|
|||||||
@@ -141,155 +141,170 @@ allowed =
|
|||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "performing aggregations on spreaded fields from an embedded resource" $ do
|
context "performing aggregations on spreaded fields from an embedded resource" $ do
|
||||||
it "supports the use of aggregates on spreaded fields" $ do
|
context "to-one spread relationships" $ do
|
||||||
get "/budget_expenses?select=total_expenses:expense_amount.sum(),...budget_categories(budget_owner,total_budget:budget_amount.sum())&order=budget_categories(budget_owner)" `shouldRespondWith`
|
it "supports the use of aggregates on spreaded fields" $ do
|
||||||
[json|[
|
get "/budget_expenses?select=total_expenses:expense_amount.sum(),...budget_categories(budget_owner,total_budget:budget_amount.sum())&order=budget_categories(budget_owner)" `shouldRespondWith`
|
||||||
{"total_expenses": 600.52,"budget_owner": "Brian Smith", "total_budget": 2000.42},
|
[json|[
|
||||||
{"total_expenses": 100.22, "budget_owner": "Jane Clarkson","total_budget": 7000.41},
|
{"total_expenses": 600.52,"budget_owner": "Brian Smith", "total_budget": 2000.42},
|
||||||
{"total_expenses": 900.27, "budget_owner": "Sally Hughes", "total_budget": 500.23}]|]
|
{"total_expenses": 100.22, "budget_owner": "Jane Clarkson","total_budget": 7000.41},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"total_expenses": 900.27, "budget_owner": "Sally Hughes", "total_budget": 500.23}]|]
|
||||||
it "supports the use of aggregates on spreaded fields when only aggregates are supplied" $ do
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/budget_expenses?select=...budget_categories(total_budget:budget_amount.sum())" `shouldRespondWith`
|
it "supports the use of aggregates on spreaded fields when only aggregates are supplied" $ do
|
||||||
[json|[{"total_budget": 9501.06}]|]
|
get "/budget_expenses?select=...budget_categories(total_budget:budget_amount.sum())" `shouldRespondWith`
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
[json|[{"total_budget": 9501.06}]|]
|
||||||
it "supports aggregates from a spread relationships grouped by spreaded fields from other relationships" $ do
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/processes?select=...process_costs(cost.sum()),...process_categories(name)" `shouldRespondWith`
|
it "supports aggregates from a spread relationships grouped by spreaded fields from other relationships" $ do
|
||||||
[json|[
|
get "/processes?select=...process_costs(cost.sum()),...process_categories(name)" `shouldRespondWith`
|
||||||
{"sum": 400.00, "name": "Batch"},
|
[json|[
|
||||||
{"sum": 320.00, "name": "Mass"}]|]
|
{"sum": 400.00, "name": "Batch"},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"sum": 350.00, "name": "Mass"}]|]
|
||||||
get "/processes?select=...process_costs(cost_sum:cost.sum()),...process_categories(category:name)" `shouldRespondWith`
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
[json|[
|
get "/processes?select=...process_costs(cost_sum:cost.sum()),...process_categories(category:name)" `shouldRespondWith`
|
||||||
{"cost_sum": 400.00, "category": "Batch"},
|
[json|[
|
||||||
{"cost_sum": 320.00, "category": "Mass"}]|]
|
{"cost_sum": 400.00, "category": "Batch"},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"cost_sum": 350.00, "category": "Mass"}]|]
|
||||||
it "supports aggregates on spreaded fields from nested relationships" $ do
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/process_supervisor?select=...processes(factory_id,...process_costs(cost.sum()))" `shouldRespondWith`
|
it "supports aggregates on spreaded fields from nested relationships" $ do
|
||||||
[json|[
|
get "/process_supervisor?select=...processes(factory_id,...process_costs(cost.sum()))" `shouldRespondWith`
|
||||||
{"factory_id": 3, "sum": 120.00},
|
[json|[
|
||||||
{"factory_id": 2, "sum": 500.00},
|
{"factory_id": 3, "sum": 110.00},
|
||||||
{"factory_id": 1, "sum": 350.00}]|]
|
{"factory_id": 2, "sum": 500.00},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"factory_id": 1, "sum": 350.00}]|]
|
||||||
get "/process_supervisor?select=...processes(factory_id,...process_costs(cost_sum:cost.sum()))" `shouldRespondWith`
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
[json|[
|
get "/process_supervisor?select=...processes(factory_id,...process_costs(cost_sum:cost.sum()))" `shouldRespondWith`
|
||||||
{"factory_id": 3, "cost_sum": 120.00},
|
[json|[
|
||||||
{"factory_id": 2, "cost_sum": 500.00},
|
{"factory_id": 3, "cost_sum": 110.00},
|
||||||
{"factory_id": 1, "cost_sum": 350.00}]|]
|
{"factory_id": 2, "cost_sum": 500.00},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"factory_id": 1, "cost_sum": 350.00}]|]
|
||||||
it "supports aggregates on spreaded fields from nested relationships, grouped by a regular nested relationship" $ do
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/process_supervisor?select=...processes(factories(name),...process_costs(cost.sum()))" `shouldRespondWith`
|
it "supports aggregates on spreaded fields from nested relationships, grouped by a regular nested relationship" $ do
|
||||||
[json|[
|
get "/process_supervisor?select=...processes(factories(name),...process_costs(cost.sum()))" `shouldRespondWith`
|
||||||
{"factories": {"name": "Factory A"}, "sum": 350.00},
|
[json|[
|
||||||
{"factories": {"name": "Factory B"}, "sum": 500.00},
|
{"factories": {"name": "Factory A"}, "sum": 350.00},
|
||||||
{"factories": {"name": "Factory C"}, "sum": 120.00}]|]
|
{"factories": {"name": "Factory B"}, "sum": 500.00},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"factories": {"name": "Factory C"}, "sum": 110.00}]|]
|
||||||
get "/process_supervisor?select=...processes(factory:factories(name),...process_costs(cost_sum:cost.sum()))" `shouldRespondWith`
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
[json|[
|
get "/process_supervisor?select=...processes(factory:factories(name),...process_costs(cost_sum:cost.sum()))" `shouldRespondWith`
|
||||||
{"factory": {"name": "Factory A"}, "cost_sum": 350.00},
|
[json|[
|
||||||
{"factory": {"name": "Factory B"}, "cost_sum": 500.00},
|
{"factory": {"name": "Factory A"}, "cost_sum": 350.00},
|
||||||
{"factory": {"name": "Factory C"}, "cost_sum": 120.00}]|]
|
{"factory": {"name": "Factory B"}, "cost_sum": 500.00},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"factory": {"name": "Factory C"}, "cost_sum": 110.00}]|]
|
||||||
it "supports aggregates on spreaded fields from nested relationships, grouped by spreaded fields from other nested relationships" $ do
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/process_supervisor?select=supervisor_id,...processes(...process_costs(cost.sum()),...process_categories(name))&order=supervisor_id" `shouldRespondWith`
|
it "supports aggregates on spreaded fields from nested relationships, grouped by spreaded fields from other nested relationships" $ do
|
||||||
[json|[
|
get "/process_supervisor?select=supervisor_id,...processes(...process_costs(cost.sum()),...process_categories(name))&order=supervisor_id" `shouldRespondWith`
|
||||||
{"supervisor_id": 1, "sum": 220.00, "name": "Batch"},
|
[json|[
|
||||||
{"supervisor_id": 2, "sum": 70.00, "name": "Batch"},
|
{"supervisor_id": 1, "sum": 220.00, "name": "Batch"},
|
||||||
{"supervisor_id": 2, "sum": 200.00, "name": "Mass"},
|
{"supervisor_id": 2, "sum": 70.00, "name": "Batch"},
|
||||||
{"supervisor_id": 3, "sum": 180.00, "name": "Batch"},
|
{"supervisor_id": 2, "sum": 200.00, "name": "Mass"},
|
||||||
{"supervisor_id": 3, "sum": 120.00, "name": "Mass"},
|
{"supervisor_id": 3, "sum": 180.00, "name": "Batch"},
|
||||||
{"supervisor_id": 4, "sum": 180.00, "name": "Batch"}]|]
|
{"supervisor_id": 3, "sum": 110.00, "name": "Mass"},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"supervisor_id": 4, "sum": 180.00, "name": "Batch"}]|]
|
||||||
get "/process_supervisor?select=supervisor_id,...processes(...process_costs(cost_sum:cost.sum()),...process_categories(category:name))&order=supervisor_id" `shouldRespondWith`
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
[json|[
|
get "/process_supervisor?select=supervisor_id,...processes(...process_costs(cost_sum:cost.sum()),...process_categories(category:name))&order=supervisor_id" `shouldRespondWith`
|
||||||
{"supervisor_id": 1, "cost_sum": 220.00, "category": "Batch"},
|
[json|[
|
||||||
{"supervisor_id": 2, "cost_sum": 70.00, "category": "Batch"},
|
{"supervisor_id": 1, "cost_sum": 220.00, "category": "Batch"},
|
||||||
{"supervisor_id": 2, "cost_sum": 200.00, "category": "Mass"},
|
{"supervisor_id": 2, "cost_sum": 70.00, "category": "Batch"},
|
||||||
{"supervisor_id": 3, "cost_sum": 180.00, "category": "Batch"},
|
{"supervisor_id": 2, "cost_sum": 200.00, "category": "Mass"},
|
||||||
{"supervisor_id": 3, "cost_sum": 120.00, "category": "Mass"},
|
{"supervisor_id": 3, "cost_sum": 180.00, "category": "Batch"},
|
||||||
{"supervisor_id": 4, "cost_sum": 180.00, "category": "Batch"}]|]
|
{"supervisor_id": 3, "cost_sum": 110.00, "category": "Mass"},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"supervisor_id": 4, "cost_sum": 180.00, "category": "Batch"}]|]
|
||||||
it "supports aggregates on spreaded fields from nested relationships, grouped by spreaded fields from other nested relationships, using a nested relationship as top parent" $ do
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/supervisors?select=name,process_supervisor(...processes(...process_costs(cost.sum()),...process_categories(name)))" `shouldRespondWith`
|
it "supports aggregates on spreaded fields from nested relationships, grouped by spreaded fields from other nested relationships, using a nested relationship as top parent" $ do
|
||||||
[json|[
|
get "/supervisors?select=name,process_supervisor(...processes(...process_costs(cost.sum()),...process_categories(name)))" `shouldRespondWith`
|
||||||
{"name": "Mary", "process_supervisor": [{"name": "Batch", "sum": 220.00}]},
|
[json|[
|
||||||
{"name": "John", "process_supervisor": [{"name": "Batch", "sum": 70.00}, {"name": "Mass", "sum": 200.00}]},
|
{"name": "Mary", "process_supervisor": [{"name": "Batch", "sum": 220.00}]},
|
||||||
{"name": "Peter", "process_supervisor": [{"name": "Batch", "sum": 180.00}, {"name": "Mass", "sum": 120.00}]},
|
{"name": "John", "process_supervisor": [{"name": "Batch", "sum": 70.00}, {"name": "Mass", "sum": 200.00}]},
|
||||||
{"name": "Sarah", "process_supervisor": [{"name": "Batch", "sum": 180.00}]}]|]
|
{"name": "Peter", "process_supervisor": [{"name": "Batch", "sum": 180.00}, {"name": "Mass", "sum": 110.00}]},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"name": "Sarah", "process_supervisor": [{"name": "Batch", "sum": 180.00}]},
|
||||||
get "/supervisors?select=name,process_supervisor(...processes(...process_costs(cost_sum:cost.sum()),...process_categories(category:name)))" `shouldRespondWith`
|
{"name": "Jane", "process_supervisor": []}]|]
|
||||||
[json|[
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
{"name": "Mary", "process_supervisor": [{"category": "Batch", "cost_sum": 220.00}]},
|
get "/supervisors?select=name,process_supervisor(...processes(...process_costs(cost_sum:cost.sum()),...process_categories(category:name)))" `shouldRespondWith`
|
||||||
{"name": "John", "process_supervisor": [{"category": "Batch", "cost_sum": 70.00}, {"category": "Mass", "cost_sum": 200.00}]},
|
[json|[
|
||||||
{"name": "Peter", "process_supervisor": [{"category": "Batch", "cost_sum": 180.00}, {"category": "Mass", "cost_sum": 120.00}]},
|
{"name": "Mary", "process_supervisor": [{"category": "Batch", "cost_sum": 220.00}]},
|
||||||
{"name": "Sarah", "process_supervisor": [{"category": "Batch", "cost_sum": 180.00}]}]|]
|
{"name": "John", "process_supervisor": [{"category": "Batch", "cost_sum": 70.00}, {"category": "Mass", "cost_sum": 200.00}]},
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{"name": "Peter", "process_supervisor": [{"category": "Batch", "cost_sum": 180.00}, {"category": "Mass", "cost_sum": 110.00}]},
|
||||||
|
{"name": "Sarah", "process_supervisor": [{"category": "Batch", "cost_sum": 180.00}]},
|
||||||
|
{"name": "Jane", "process_supervisor": []}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "supports count() aggregate without specifying a field" $ do
|
context "supports count() aggregate without specifying a field" $ do
|
||||||
it "works by itself in the embedded resource" $ do
|
it "works by itself in the embedded resource" $ do
|
||||||
get "/process_supervisor?select=supervisor_id,...processes(count())&order=supervisor_id" `shouldRespondWith`
|
get "/process_supervisor?select=supervisor_id,...processes(count())&order=supervisor_id" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"supervisor_id": 1, "count": 2},
|
{"supervisor_id": 1, "count": 2},
|
||||||
{"supervisor_id": 2, "count": 2},
|
{"supervisor_id": 2, "count": 2},
|
||||||
{"supervisor_id": 3, "count": 3},
|
{"supervisor_id": 3, "count": 3},
|
||||||
{"supervisor_id": 4, "count": 1}]|]
|
{"supervisor_id": 4, "count": 1}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/process_supervisor?select=supervisor_id,...processes(processes_count:count())&order=supervisor_id" `shouldRespondWith`
|
get "/process_supervisor?select=supervisor_id,...processes(processes_count:count())&order=supervisor_id" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"supervisor_id": 1, "processes_count": 2},
|
{"supervisor_id": 1, "processes_count": 2},
|
||||||
{"supervisor_id": 2, "processes_count": 2},
|
{"supervisor_id": 2, "processes_count": 2},
|
||||||
{"supervisor_id": 3, "processes_count": 3},
|
{"supervisor_id": 3, "processes_count": 3},
|
||||||
{"supervisor_id": 4, "processes_count": 1}]|]
|
{"supervisor_id": 4, "processes_count": 1}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
it "works alongside other columns in the embedded resource" $ do
|
it "works alongside other columns in the embedded resource" $ do
|
||||||
get "/process_supervisor?select=...supervisors(id,count())&order=supervisors(id)" `shouldRespondWith`
|
get "/process_supervisor?select=...supervisors(id,count())&order=supervisors(id)" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"id": 1, "count": 2},
|
{"id": 1, "count": 2},
|
||||||
{"id": 2, "count": 2},
|
{"id": 2, "count": 2},
|
||||||
{"id": 3, "count": 3},
|
{"id": 3, "count": 3},
|
||||||
{"id": 4, "count": 1}]|]
|
{"id": 4, "count": 1}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/process_supervisor?select=...supervisors(supervisor:id,supervisor_count:count())&order=supervisors(supervisor)" `shouldRespondWith`
|
get "/process_supervisor?select=...supervisors(supervisor:id,supervisor_count:count())&order=supervisors(supervisor)" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"supervisor": 1, "supervisor_count": 2},
|
{"supervisor": 1, "supervisor_count": 2},
|
||||||
{"supervisor": 2, "supervisor_count": 2},
|
{"supervisor": 2, "supervisor_count": 2},
|
||||||
{"supervisor": 3, "supervisor_count": 3},
|
{"supervisor": 3, "supervisor_count": 3},
|
||||||
{"supervisor": 4, "supervisor_count": 1}]|]
|
{"supervisor": 4, "supervisor_count": 1}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
it "works on nested resources" $ do
|
it "works on nested resources" $ do
|
||||||
get "/process_supervisor?select=supervisor_id,...processes(...process_costs(count()))&order=supervisor_id" `shouldRespondWith`
|
get "/process_supervisor?select=supervisor_id,...processes(...process_costs(count()))&order=supervisor_id" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"supervisor_id": 1, "count": 2},
|
{"supervisor_id": 1, "count": 2},
|
||||||
{"supervisor_id": 2, "count": 2},
|
{"supervisor_id": 2, "count": 2},
|
||||||
{"supervisor_id": 3, "count": 2},
|
{"supervisor_id": 3, "count": 3},
|
||||||
{"supervisor_id": 4, "count": 1}]|]
|
{"supervisor_id": 4, "count": 1}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/process_supervisor?select=supervisor:supervisor_id,...processes(...process_costs(process_costs_count:count()))&order=supervisor_id" `shouldRespondWith`
|
get "/process_supervisor?select=supervisor:supervisor_id,...processes(...process_costs(process_costs_count:count()))&order=supervisor_id" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"supervisor": 1, "process_costs_count": 2},
|
{"supervisor": 1, "process_costs_count": 2},
|
||||||
{"supervisor": 2, "process_costs_count": 2},
|
{"supervisor": 2, "process_costs_count": 2},
|
||||||
{"supervisor": 3, "process_costs_count": 2},
|
{"supervisor": 3, "process_costs_count": 3},
|
||||||
{"supervisor": 4, "process_costs_count": 1}]|]
|
{"supervisor": 4, "process_costs_count": 1}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
it "works on nested resources grouped by spreaded fields" $ do
|
it "works on nested resources grouped by spreaded fields" $ do
|
||||||
get "/process_supervisor?select=...processes(factory_id,...process_costs(count()))&order=processes(factory_id)" `shouldRespondWith`
|
get "/process_supervisor?select=...processes(factory_id,...process_costs(count()))&order=processes(factory_id)" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"factory_id": 1, "count": 2},
|
{"factory_id": 1, "count": 2},
|
||||||
{"factory_id": 2, "count": 4},
|
{"factory_id": 2, "count": 4},
|
||||||
{"factory_id": 3, "count": 1}]|]
|
{"factory_id": 3, "count": 2}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/process_supervisor?select=...processes(factory:factory_id,...process_costs(process_costs_count:count()))&order=processes(factory)" `shouldRespondWith`
|
get "/process_supervisor?select=...processes(factory:factory_id,...process_costs(process_costs_count:count()))&order=processes(factory)" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"factory": 1, "process_costs_count": 2},
|
{"factory": 1, "process_costs_count": 2},
|
||||||
{"factory": 2, "process_costs_count": 4},
|
{"factory": 2, "process_costs_count": 4},
|
||||||
{"factory": 3, "process_costs_count": 1}]|]
|
{"factory": 3, "process_costs_count": 2}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
it "works on different levels of the nested resources at the same time" $
|
it "works on different levels of the nested resources at the same time" $
|
||||||
get "/process_supervisor?select=...processes(factory:factory_id,processes_count:count(),...process_costs(process_costs_count:count()))&order=processes(factory)" `shouldRespondWith`
|
get "/process_supervisor?select=...processes(factory:factory_id,processes_count:count(),...process_costs(process_costs_count:count()))&order=processes(factory)" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"factory": 1, "processes_count": 2, "process_costs_count": 2},
|
{"factory": 1, "processes_count": 2, "process_costs_count": 2},
|
||||||
{"factory": 2, "processes_count": 4, "process_costs_count": 4},
|
{"factory": 2, "processes_count": 4, "process_costs_count": 4},
|
||||||
{"factory": 3, "processes_count": 2, "process_costs_count": 1}]|]
|
{"factory": 3, "processes_count": 2, "process_costs_count": 2}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "to-many spread relationships" $ do
|
||||||
|
it "does not support the use of aggregates" $ do
|
||||||
|
get "/factories?select=name,...factory_buildings(type,size.sum())" `shouldRespondWith`
|
||||||
|
[json|{
|
||||||
|
"code": "PGRST127",
|
||||||
|
"message":"Feature not implemented",
|
||||||
|
"details":"Aggregates are not implemented for one-to-many or many-to-many spreads.",
|
||||||
|
"hint":null
|
||||||
|
}|]
|
||||||
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
disallowed :: SpecWith ((), Application)
|
disallowed :: SpecWith ((), Application)
|
||||||
disallowed =
|
disallowed =
|
||||||
@@ -316,7 +331,7 @@ disallowed =
|
|||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
, matchHeaders = [matchContentTypeJson] }
|
, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "prevents the use of aggregates on spread embeds" $
|
it "prevents the use of aggregates on to-one spread embeds" $
|
||||||
get "/project_invoices?select=...projects(id.count())" `shouldRespondWith`
|
get "/project_invoices?select=...projects(id.count())" `shouldRespondWith`
|
||||||
[json|{
|
[json|{
|
||||||
"hint":null,
|
"hint":null,
|
||||||
@@ -326,3 +341,14 @@ disallowed =
|
|||||||
}|]
|
}|]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
, matchHeaders = [matchContentTypeJson] }
|
, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "prevents the use of aggregates on to-many spread embeds" $
|
||||||
|
get "/factories?select=...processes(id.count())" `shouldRespondWith`
|
||||||
|
[json|{
|
||||||
|
"hint":null,
|
||||||
|
"details":null,
|
||||||
|
"code":"PGRST123",
|
||||||
|
"message":"Use of aggregate functions is not allowed"
|
||||||
|
}|]
|
||||||
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|||||||
@@ -63,28 +63,6 @@ spec =
|
|||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "fails when is not a to-one relationship" $ do
|
|
||||||
get "/clients?select=*,...projects(*)" `shouldRespondWith`
|
|
||||||
[json|{
|
|
||||||
"code":"PGRST119",
|
|
||||||
"details":"'clients' and 'projects' do not form a many-to-one or one-to-one relationship",
|
|
||||||
"hint":null,
|
|
||||||
"message":"A spread operation on 'projects' is not possible"
|
|
||||||
}|]
|
|
||||||
{ matchStatus = 400
|
|
||||||
, matchHeaders = [matchContentTypeJson]
|
|
||||||
}
|
|
||||||
get "/designers?select=*,...computed_videogames(*)" `shouldRespondWith`
|
|
||||||
[json|{
|
|
||||||
"code":"PGRST119",
|
|
||||||
"details":"'designers' and 'computed_videogames' do not form a many-to-one or one-to-one relationship",
|
|
||||||
"hint":null,
|
|
||||||
"message":"A spread operation on 'computed_videogames' is not possible"
|
|
||||||
}|]
|
|
||||||
{ matchStatus = 400
|
|
||||||
, matchHeaders = [matchContentTypeJson]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "can include or exclude attributes of the junction on a m2m" $ do
|
it "can include or exclude attributes of the junction on a m2m" $ do
|
||||||
get "/users?select=*,tasks:users_tasks(*,...tasks(*))&limit=1" `shouldRespondWith`
|
get "/users?select=*,tasks:users_tasks(*,...tasks(*))&limit=1" `shouldRespondWith`
|
||||||
[json|[{
|
[json|[{
|
||||||
@@ -112,3 +90,513 @@ spec =
|
|||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context "one-to-many relationships" $ do
|
||||||
|
it "should spread a column as a json array" $ do
|
||||||
|
get "/factories?select=factory:name,...processes(name)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","name":["Process A1", "Process A2"]},
|
||||||
|
{"factory":"Factory B","name":["Process B1", "Process B2"]},
|
||||||
|
{"factory":"Factory C","name":["Process C1", "Process C2", "Process XX", "Process YY"]},
|
||||||
|
{"factory":"Factory D","name":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
get "/factories?select=factory:name,...processes(processes:name)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","processes":["Process A1", "Process A2"]},
|
||||||
|
{"factory":"Factory B","processes":["Process B1", "Process B2"]},
|
||||||
|
{"factory":"Factory C","processes":["Process C1", "Process C2", "Process XX", "Process YY"]},
|
||||||
|
{"factory":"Factory D","processes":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should spread many columns as json arrays" $ do
|
||||||
|
get "/factories?select=factory:name,...processes(name,category_id)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","name":["Process A1", "Process A2"],"category_id":[1, 2]},
|
||||||
|
{"factory":"Factory B","name":["Process B1", "Process B2"],"category_id":[1, 1]},
|
||||||
|
{"factory":"Factory C","name":["Process C1", "Process C2", "Process XX", "Process YY"],"category_id":[2, 2, 2, 2]},
|
||||||
|
{"factory":"Factory D","name":[],"category_id":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
get "/factories?select=factory:name,...processes(processes:name,categories:category_id)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","processes":["Process A1", "Process A2"],"categories":[1, 2]},
|
||||||
|
{"factory":"Factory B","processes":["Process B1", "Process B2"],"categories":[1, 1]},
|
||||||
|
{"factory":"Factory C","processes":["Process C1", "Process C2", "Process XX", "Process YY"],"categories":[2, 2, 2, 2]},
|
||||||
|
{"factory":"Factory D","processes":[],"categories":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should return an empty array when no elements are found" $
|
||||||
|
get "/factories?select=factory:name,...processes(processes:name)&processes=is.null" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory D","processes":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should return a single null element array, not an empty one, when the row exists but the value happens to be null" $
|
||||||
|
get "/managers?select=name,...organizations(organizations:name,referees:referee)&id=eq.1" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"name":"Referee Manager","organizations":["Referee Org"],"referees":[null]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should work when selecting all columns" $
|
||||||
|
get "/factories?select=factory:name,...processes(*)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2]},
|
||||||
|
{"factory":"Factory B","id":[3, 4],"name":["Process B1", "Process B2"],"factory_id":[2, 2],"category_id":[1, 1]},
|
||||||
|
{"factory":"Factory C","id":[5, 6, 7, 8],"name":["Process C1", "Process C2", "Process XX", "Process YY"],"factory_id":[3, 3, 3, 3],"category_id":[2, 2, 2, 2]},
|
||||||
|
{"factory":"Factory D","id":[],"name":[],"factory_id":[],"category_id":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should select spread columns from a nested one-to-one relationship" $
|
||||||
|
get "/factories?select=factory:name,...processes(process:name,...process_costs(process_costs:cost))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","process":["Process A1", "Process A2"],"process_costs":[150.00, 200.00]},
|
||||||
|
{"factory":"Factory B","process":["Process B1", "Process B2"],"process_costs":[180.00, 70.00]},
|
||||||
|
{"factory":"Factory C","process":["Process C1", "Process C2", "Process YY", "Process XX"],"process_costs":[40.00, 70.00, 40.00, null]},
|
||||||
|
{"factory":"Factory D","process":[],"process_costs":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should select spread columns from a nested many-to-one relationship" $
|
||||||
|
get "/factories?select=factory:name,...processes(process:name,...process_categories(categories:name))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","process":["Process A1", "Process A2"],"categories":["Batch", "Mass"]},
|
||||||
|
{"factory":"Factory B","process":["Process B2", "Process B1"],"categories":["Batch", "Batch"]},
|
||||||
|
{"factory":"Factory C","process":["Process YY", "Process XX", "Process C2", "Process C1"],"categories":["Mass", "Mass", "Mass", "Mass"]},
|
||||||
|
{"factory":"Factory D","process":[],"categories":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should select spread columns from a nested one-to-many relationship" $
|
||||||
|
get "/factories?select=factory:name,...processes(process:name,...process_supervisor(supervisor_ids:supervisor_id))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","process":["Process A1", "Process A2"],"supervisor_ids":[[1], [2]]},
|
||||||
|
{"factory":"Factory B","process":["Process B1", "Process B2"],"supervisor_ids":[[3, 4], [1, 2]]},
|
||||||
|
{"factory":"Factory C","process":["Process C1", "Process C2", "Process XX", "Process YY"],"supervisor_ids":[[3], [3], [], []]},
|
||||||
|
{"factory":"Factory D","process":[],"supervisor_ids":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should select spread columns from a nested many-to-many relationship" $ do
|
||||||
|
get "/factories?select=factory:name,...processes(process:name,...supervisors(supervisors:name))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","process":["Process A1", "Process A2"],"supervisors":[["Mary"], ["John"]]},
|
||||||
|
{"factory":"Factory B","process":["Process B1", "Process B2"],"supervisors":[["Peter", "Sarah"], ["Mary", "John"]]},
|
||||||
|
{"factory":"Factory C","process":["Process C1", "Process C2", "Process XX", "Process YY"],"supervisors":[["Peter"], ["Peter"], [], []]},
|
||||||
|
{"factory":"Factory D","process":[],"supervisors":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show a nested non-spread one-to-one relationship as an array of objects" $ do
|
||||||
|
get "/factories?select=factory:name,...processes(process:name,process_costs(cost))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","process":["Process A1", "Process A2"],"process_costs":[{"cost": 150.00}, {"cost": 200.00}]},
|
||||||
|
{"factory":"Factory B","process":["Process B1", "Process B2"],"process_costs":[{"cost": 180.00}, {"cost": 70.00}]},
|
||||||
|
{"factory":"Factory C","process":["Process C1", "Process C2", "Process YY", "Process XX"],"process_costs":[{"cost": 40.00}, {"cost": 70.00}, {"cost": 40.00}, null]},
|
||||||
|
{"factory":"Factory D","process":[],"process_costs":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show a nested non-spread many-to-one relationship as an array of objects" $
|
||||||
|
get "/factories?select=factory:name,...processes(process:name,process_categories(name))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","process":["Process A1", "Process A2"],"process_categories":[{"name": "Batch"}, {"name": "Mass"}]},
|
||||||
|
{"factory":"Factory B","process":["Process B2", "Process B1"],"process_categories":[{"name": "Batch"}, {"name": "Batch"}]},
|
||||||
|
{"factory":"Factory C","process":["Process YY", "Process XX", "Process C2", "Process C1"],"process_categories":[{"name": "Mass"}, {"name": "Mass"}, {"name": "Mass"}, {"name": "Mass"}]},
|
||||||
|
{"factory":"Factory D","process":[],"process_categories":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show a nested non-spread one-to-many relationship as an array of arrays" $
|
||||||
|
get "/factories?select=factory:name,...processes(process:name,process_supervisor(supervisor_id))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","process":["Process A1", "Process A2"],"process_supervisor":[[{"supervisor_id": 1}], [{"supervisor_id": 2}]]},
|
||||||
|
{"factory":"Factory B","process":["Process B1", "Process B2"],"process_supervisor":[[{"supervisor_id": 3}, {"supervisor_id": 4}], [{"supervisor_id": 1}, {"supervisor_id": 2}]]},
|
||||||
|
{"factory":"Factory C","process":["Process C1", "Process C2", "Process XX", "Process YY"],"process_supervisor":[[{"supervisor_id": 3}], [{"supervisor_id": 3}], [], []]},
|
||||||
|
{"factory":"Factory D","process":[],"process_supervisor":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show a nested non-spread many-to-many relationship as an array of arrays" $
|
||||||
|
get "/factories?select=factory:name,...processes(process:name,supervisors(name))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","process":["Process A1", "Process A2"],"supervisors":[[{"name": "Mary"}], [{"name": "John"}]]},
|
||||||
|
{"factory":"Factory B","process":["Process B1", "Process B2"],"supervisors":[[{"name": "Peter"}, {"name": "Sarah"}], [{"name": "Mary"}, {"name": "John"}]]},
|
||||||
|
{"factory":"Factory C","process":["Process C1", "Process C2", "Process XX", "Process YY"],"supervisors":[[{"name": "Peter"}], [{"name": "Peter"}], [], []]},
|
||||||
|
{"factory":"Factory D","process":[],"supervisors":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should work when selecting all columns in a nested to-one resource" $
|
||||||
|
get "/factories?select=factory:name,...processes(*,...process_costs(*))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2],"process_id":[1, 2],"cost":[150.00, 200.00]},
|
||||||
|
{"factory":"Factory B","id":[3, 4],"name":["Process B1", "Process B2"],"factory_id":[2, 2],"category_id":[1, 1],"process_id":[3, 4],"cost":[180.00, 70.00]},
|
||||||
|
{"factory":"Factory C","id":[5, 6, 8, 7],"name":["Process C1", "Process C2", "Process YY", "Process XX"],"factory_id":[3, 3, 3, 3],"category_id":[2, 2, 2, 2],"process_id":[5, 6, 8, null],"cost":[40.00, 70.00, 40.00, null]},
|
||||||
|
{"factory":"Factory D","id":[],"name":[],"factory_id":[],"category_id":[],"process_id":[],"cost":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "works when column filters are specified" $
|
||||||
|
get "/factories?select=factory:name,...processes(*)&processes.name=not.like.*1&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","id":[2],"name":["Process A2"],"factory_id":[1],"category_id":[2]},
|
||||||
|
{"factory":"Factory B","id":[4],"name":["Process B2"],"factory_id":[2],"category_id":[1]},
|
||||||
|
{"factory":"Factory C","id":[6, 7, 8],"name":["Process C2", "Process XX", "Process YY"],"factory_id":[3, 3, 3],"category_id":[2, 2, 2]},
|
||||||
|
{"factory":"Factory D","id":[],"name":[],"factory_id":[],"category_id":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "works with inner joins or not.is.null filters" $ do
|
||||||
|
get "/factories?select=factory:name,...processes!inner(name)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","name":["Process A1", "Process A2"]},
|
||||||
|
{"factory":"Factory B","name":["Process B1", "Process B2"]},
|
||||||
|
{"factory":"Factory C","name":["Process C1", "Process C2", "Process XX", "Process YY"]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
get "/factories?select=factory:name,...processes(name)&processes=not.is.null&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","name":["Process A1", "Process A2"]},
|
||||||
|
{"factory":"Factory B","name":["Process B1", "Process B2"]},
|
||||||
|
{"factory":"Factory C","name":["Process C1", "Process C2", "Process XX", "Process YY"]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "orders all the resulting arrays according to the spread relationship ordering columns" $ do
|
||||||
|
get "/factories?select=factory:name,...processes(*)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2]},
|
||||||
|
{"factory":"Factory B","id":[4, 3],"name":["Process B2", "Process B1"],"factory_id":[2, 2],"category_id":[1, 1]},
|
||||||
|
{"factory":"Factory C","id":[8, 7, 6, 5],"name":["Process YY", "Process XX", "Process C2", "Process C1"],"factory_id":[3, 3, 3, 3],"category_id":[2, 2, 2, 2]},
|
||||||
|
{"factory":"Factory D","id":[],"name":[],"factory_id":[],"category_id":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
get "/factories?select=factory:name,...factory_buildings(*)&factory_buildings.order=inspections->pending.asc.nullsfirst,inspections->ins.desc&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","id":[2, 1],"code":["A002", "A001"],"size":[200, 150],"type":["A", "A"],"factory_id":[1, 1],"inspections":[{"ins": "2025A", "pending": true}, {"ins": "2024C", "pending": true}]},
|
||||||
|
{"factory":"Factory B","id":[4, 3],"code":["B002", "B001"],"size":[120, 50],"type":["C", "B"],"factory_id":[2, 2],"inspections":[{"ins": "2023A"}, {"ins": "2025A", "pending": true}]},
|
||||||
|
{"factory":"Factory C","id":[5],"code":["C001"],"size":[240],"type":["B"],"factory_id":[3],"inspections":[{"ins": "2022B"}]},
|
||||||
|
{"factory":"Factory D","id":[6],"code":["D001"],"size":[310],"type":["A"],"factory_id":[4],"inspections":[{"ins": "2024C", "pending": true}]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "orders all the resulting arrays according to the spread relationship ordering columns even if they aren't selected" $
|
||||||
|
get "/factories?select=factory:name,...processes(name)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","name":["Process A1", "Process A2"]},
|
||||||
|
{"factory":"Factory B","name":["Process B2", "Process B1"]},
|
||||||
|
{"factory":"Factory C","name":["Process YY", "Process XX", "Process C2", "Process C1"]},
|
||||||
|
{"factory":"Factory D","name":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "orders all the resulting arrays according to the related ordering columns in the spread relationship" $
|
||||||
|
get "/factories?select=factory:name,...processes(name,...process_costs(cost))&processes.order=process_costs(cost)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"factory":"Factory A","name":["Process A1", "Process A2"],"cost":[150.00, 200.00]},
|
||||||
|
{"factory":"Factory B","name":["Process B2", "Process B1"],"cost":[70.00, 180.00]},
|
||||||
|
{"factory":"Factory C","name":["Process C1", "Process YY", "Process C2", "Process XX"],"cost":[40.00, 40.00, 70.00, null]},
|
||||||
|
{"factory":"Factory D","name":[],"cost":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "many-to-many relationships" $ do
|
||||||
|
it "should spread a column as a json array" $ do
|
||||||
|
get "/operators?select=operator:name,...processes(name)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","name":["Process C2", "Process XX"]},
|
||||||
|
{"operator":"Anne","name":["Process A1", "Process A2", "Process B2"]},
|
||||||
|
{"operator":"Jeff","name":["Process A2", "Process B1", "Process B2", "Process C2"]},
|
||||||
|
{"operator":"Liz","name":[]},
|
||||||
|
{"operator":"Louis","name":["Process A1", "Process A2"]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
get "/operators?select=operator:name,...processes(processes:name)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","processes":["Process C2", "Process XX"]},
|
||||||
|
{"operator":"Anne","processes":["Process A1", "Process A2", "Process B2"]},
|
||||||
|
{"operator":"Jeff","processes":["Process A2", "Process B1", "Process B2", "Process C2"]},
|
||||||
|
{"operator":"Liz","processes":[]},
|
||||||
|
{"operator":"Louis","processes":["Process A1", "Process A2"]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should spread many columns as json arrays" $ do
|
||||||
|
get "/operators?select=operator:name,...processes(name,category_id)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","name":["Process C2", "Process XX"],"category_id":[2, 2]},
|
||||||
|
{"operator":"Anne","name":["Process A1", "Process A2", "Process B2"],"category_id":[1, 2, 1]},
|
||||||
|
{"operator":"Jeff","name":["Process A2", "Process B1", "Process B2", "Process C2"],"category_id":[2, 1, 1, 2]},
|
||||||
|
{"operator":"Liz","name":[],"category_id":[]},
|
||||||
|
{"operator":"Louis","name":["Process A1", "Process A2"],"category_id":[1, 2]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
get "/operators?select=operator:name,...processes(processes:name,categories:category_id)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","processes":["Process C2", "Process XX"],"categories":[2, 2]},
|
||||||
|
{"operator":"Anne","processes":["Process A1", "Process A2", "Process B2"],"categories":[1, 2, 1]},
|
||||||
|
{"operator":"Jeff","processes":["Process A2", "Process B1", "Process B2", "Process C2"],"categories":[2, 1, 1, 2]},
|
||||||
|
{"operator":"Liz","processes":[],"categories":[]},
|
||||||
|
{"operator":"Louis","processes":["Process A1", "Process A2"],"categories":[1, 2]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should return an empty array when no elements are found" $
|
||||||
|
get "/operators?select=operator:name,...processes(processes:name)&processes=is.null" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Liz","processes":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should return a single null element array, not an empty one, when the row exists but the value happens to be null" $
|
||||||
|
get "/operators?select=name,...processes(process:name,...process_costs(cost)))&id=eq.5&processes.id=eq.7" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"name":"Alfred","process":["Process XX"],"cost":[null]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should work when selecting all columns" $
|
||||||
|
get "/operators?select=operator:name,...processes(*)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","id":[6, 7],"name":["Process C2", "Process XX"],"factory_id":[3, 3],"category_id":[2, 2]},
|
||||||
|
{"operator":"Anne","id":[1, 2, 4],"name":["Process A1", "Process A2", "Process B2"],"factory_id":[1, 1, 2],"category_id":[1, 2, 1]},
|
||||||
|
{"operator":"Jeff","id":[2, 3, 4, 6],"name":["Process A2", "Process B1", "Process B2", "Process C2"],"factory_id":[1, 2, 2, 3],"category_id":[2, 1, 1, 2]},
|
||||||
|
{"operator":"Liz","id":[],"name":[],"factory_id":[],"category_id":[]},
|
||||||
|
{"operator":"Louis","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show spread columns from a nested one-to-one relationship" $
|
||||||
|
get "/operators?select=operator:name,...processes(process:name,...process_costs(process_costs:cost))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","process":["Process C2", "Process XX"],"process_costs":[70.00, null]},
|
||||||
|
{"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"process_costs":[150.00, 200.00, 70.00]},
|
||||||
|
{"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"process_costs":[200.00, 180.00, 70.00, 70.00]},
|
||||||
|
{"operator":"Liz","process":[],"process_costs":[]},
|
||||||
|
{"operator":"Louis","process":["Process A1", "Process A2"],"process_costs":[150.00, 200.00]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show spread columns from a nested many-to-one relationship" $
|
||||||
|
get "/operators?select=operator:name,...processes(process:name,...process_categories(categories:name))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","process":["Process C2", "Process XX"],"categories":["Mass", "Mass"]},
|
||||||
|
{"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"categories":["Batch", "Mass", "Batch"]},
|
||||||
|
{"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"categories":["Mass", "Batch", "Batch", "Mass"]},
|
||||||
|
{"operator":"Liz","process":[],"categories":[]},
|
||||||
|
{"operator":"Louis","process":["Process A1", "Process A2"],"categories":["Batch", "Mass"]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show spread columns from a nested one-to-many relationship" $
|
||||||
|
get "/operators?select=operator:name,...processes(process:name,...process_supervisor(supervisor_ids:supervisor_id))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","process":["Process C2", "Process XX"],"supervisor_ids":[[3], []]},
|
||||||
|
{"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"supervisor_ids":[[1], [2], [1, 2]]},
|
||||||
|
{"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"supervisor_ids":[[2], [3, 4], [1, 2], [3]]},
|
||||||
|
{"operator":"Liz","process":[],"supervisor_ids":[]},
|
||||||
|
{"operator":"Louis","process":["Process A1", "Process A2"],"supervisor_ids":[[1], [2]]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show spread columns from a nested many-to-many relationship" $ do
|
||||||
|
get "/operators?select=operator:name,...processes(process:name,...supervisors(supervisors:name))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","process":["Process C2", "Process XX"],"supervisors":[["Peter"], []]},
|
||||||
|
{"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"supervisors":[["Mary"], ["John"], ["Mary", "John"]]},
|
||||||
|
{"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"supervisors":[["John"], ["Peter", "Sarah"], ["Mary", "John"], ["Peter"]]},
|
||||||
|
{"operator":"Liz","process":[],"supervisors":[]},
|
||||||
|
{"operator":"Louis","process":["Process A1", "Process A2"],"supervisors":[["Mary"], ["John"]]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show a nested non-spread one-to-one relationship as an array of objects" $ do
|
||||||
|
get "/operators?select=operator:name,...processes(process:name,process_costs(cost))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","process":["Process C2", "Process XX"],"process_costs":[{"cost": 70.00}, null]},
|
||||||
|
{"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"process_costs":[{"cost": 150.00}, {"cost": 200.00}, {"cost": 70.00}]},
|
||||||
|
{"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"process_costs":[{"cost": 200.00}, {"cost": 180.00}, {"cost": 70.00}, {"cost": 70.00}]},
|
||||||
|
{"operator":"Liz","process":[],"process_costs":[]},
|
||||||
|
{"operator":"Louis","process":["Process A1", "Process A2"],"process_costs":[{"cost": 150.00}, {"cost": 200.00}]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show a nested non-spread many-to-one relationship as an array of objects" $
|
||||||
|
get "/operators?select=operator:name,...processes(process:name,process_categories(name))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","process":["Process C2", "Process XX"],"process_categories":[{"name": "Mass"}, {"name": "Mass"}]},
|
||||||
|
{"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"process_categories":[{"name": "Batch"}, {"name": "Mass"}, {"name": "Batch"}]},
|
||||||
|
{"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"process_categories":[{"name": "Mass"}, {"name": "Batch"}, {"name": "Batch"}, {"name": "Mass"}]},
|
||||||
|
{"operator":"Liz","process":[],"process_categories":[]},
|
||||||
|
{"operator":"Louis","process":["Process A1", "Process A2"],"process_categories":[{"name": "Batch"}, {"name": "Mass"}]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show a nested non-spread one-to-many relationship as an array of arrays" $
|
||||||
|
get "/operators?select=operator:name,...processes(process:name,process_supervisor(supervisor_id))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","process":["Process C2", "Process XX"],"process_supervisor":[[{"supervisor_id": 3}], []]},
|
||||||
|
{"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"process_supervisor":[[{"supervisor_id": 1}], [{"supervisor_id": 2}], [{"supervisor_id": 1}, {"supervisor_id": 2}]]},
|
||||||
|
{"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"process_supervisor":[[{"supervisor_id": 2}], [{"supervisor_id": 3}, {"supervisor_id": 4}], [{"supervisor_id": 1}, {"supervisor_id": 2}], [{"supervisor_id": 3}]]},
|
||||||
|
{"operator":"Liz","process":[],"process_supervisor":[]},
|
||||||
|
{"operator":"Louis","process":["Process A1", "Process A2"],"process_supervisor":[[{"supervisor_id": 1}], [{"supervisor_id": 2}]]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should show a nested non-spread many-to-many relationship as an array of arrays" $
|
||||||
|
get "/operators?select=operator:name,...processes(process:name,supervisors(name))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","process":["Process C2", "Process XX"],"supervisors":[[{"name": "Peter"}], []]},
|
||||||
|
{"operator":"Anne","process":["Process A1", "Process A2", "Process B2"],"supervisors":[[{"name": "Mary"}], [{"name": "John"}], [{"name": "Mary"}, {"name": "John"}]]},
|
||||||
|
{"operator":"Jeff","process":["Process A2", "Process B1", "Process B2", "Process C2"],"supervisors":[[{"name": "John"}], [{"name": "Peter"}, {"name": "Sarah"}], [{"name": "Mary"}, {"name": "John"}], [{"name": "Peter"}]]},
|
||||||
|
{"operator":"Liz","process":[],"supervisors":[]},
|
||||||
|
{"operator":"Louis","process":["Process A1", "Process A2"],"supervisors":[[{"name": "Mary"}], [{"name": "John"}]]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "should work when selecting all columns in a nested to-one resource" $
|
||||||
|
get "/operators?select=operator:name,...processes(*,...process_costs(*))&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"operator":"Alfred","id":[6, 7],"name":["Process C2", "Process XX"],"factory_id":[3, 3],"category_id":[2, 2],"process_id":[6, null],"cost":[70.00, null]},
|
||||||
|
{"operator":"Anne","id":[1, 2, 4],"name":["Process A1", "Process A2", "Process B2"],"factory_id":[1, 1, 2],"category_id":[1, 2, 1],"process_id":[1, 2, 4],"cost":[150.00, 200.00, 70.00]},
|
||||||
|
{"operator":"Jeff","id":[2, 3, 4, 6],"name":["Process A2", "Process B1", "Process B2", "Process C2"],"factory_id":[1, 2, 2, 3],"category_id":[2, 1, 1, 2],"process_id":[2, 3, 4, 6],"cost":[200.00, 180.00, 70.00, 70.00]},
|
||||||
|
{"operator":"Liz","id":[],"name":[],"factory_id":[],"category_id":[],"process_id":[],"cost":[]},
|
||||||
|
{"operator":"Louis","id":[1, 2],"name":["Process A1", "Process A2"],"factory_id":[1, 1],"category_id":[1, 2],"process_id":[1, 2],"cost":[150.00, 200.00]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "works when column filters are specified" $
|
||||||
|
get "/supervisors?select=supervisor:name,...processes(*)&processes.name=not.like.*1&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"supervisor":"Jane","id":[],"name":[],"factory_id":[],"category_id":[]},
|
||||||
|
{"supervisor":"John","id":[2, 4],"name":["Process A2", "Process B2"],"factory_id":[1, 2],"category_id":[2, 1]},
|
||||||
|
{"supervisor":"Mary","id":[4],"name":["Process B2"],"factory_id":[2],"category_id":[1]},
|
||||||
|
{"supervisor":"Peter","id":[6],"name":["Process C2"],"factory_id":[3],"category_id":[2]},
|
||||||
|
{"supervisor":"Sarah","id":[],"name":[],"factory_id":[],"category_id":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "works with inner joins or not.is.null filters" $ do
|
||||||
|
get "/supervisors?select=supervisor:name,...processes!inner(name)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"supervisor":"John","name":["Process A2", "Process B2"]},
|
||||||
|
{"supervisor":"Mary","name":["Process A1", "Process B2"]},
|
||||||
|
{"supervisor":"Peter","name":["Process B1", "Process C1", "Process C2"]},
|
||||||
|
{"supervisor":"Sarah","name":["Process B1"]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
get "/supervisors?select=supervisor:name,...processes(name)&processes=not.is.null&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"supervisor":"John","name":["Process A2", "Process B2"]},
|
||||||
|
{"supervisor":"Mary","name":["Process A1", "Process B2"]},
|
||||||
|
{"supervisor":"Peter","name":["Process B1", "Process C1", "Process C2"]},
|
||||||
|
{"supervisor":"Sarah","name":["Process B1"]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "orders all the resulting arrays according to the spread relationship ordering columns" $ do
|
||||||
|
get "/supervisors?select=supervisor:name,...processes(*)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"supervisor":"Jane","id":[],"name":[],"factory_id":[],"category_id":[]},
|
||||||
|
{"supervisor":"John","id":[4, 2],"name":["Process B2", "Process A2"],"factory_id":[2, 1],"category_id":[1, 2]},
|
||||||
|
{"supervisor":"Mary","id":[4, 1],"name":["Process B2", "Process A1"],"factory_id":[2, 1],"category_id":[1, 1]},
|
||||||
|
{"supervisor":"Peter","id":[3, 6, 5],"name":["Process B1", "Process C2", "Process C1"],"factory_id":[2, 3, 3],"category_id":[1, 2, 2]},
|
||||||
|
{"supervisor":"Sarah","id":[3],"name":["Process B1"],"factory_id":[2],"category_id":[1]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
get "/processes?select=process:name,...operators(*)&operators.order=status->afk.asc.nullsfirst,status->id.desc&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"process":"Process A1","id":[2, 1],"name":["Louis", "Anne"],"status":[{"id": "012345"}, {"id": "543210", "afk": true}]},
|
||||||
|
{"process":"Process A2","id":[2, 3, 1],"name":["Louis", "Jeff", "Anne"],"status":[{"id": "012345"}, {"id": "666666", "afk": true}, {"id": "543210", "afk": true}]},
|
||||||
|
{"process":"Process B1","id":[3],"name":["Jeff"],"status":[{"id": "666666", "afk": true}]},
|
||||||
|
{"process":"Process B2","id":[3, 1],"name":["Jeff", "Anne"],"status":[{"id": "666666", "afk": true}, {"id": "543210", "afk": true}]},
|
||||||
|
{"process":"Process C1","id":[],"name":[],"status":[]},
|
||||||
|
{"process":"Process C2","id":[5, 3],"name":["Alfred", "Jeff"],"status":[{"id": "000000"}, {"id": "666666", "afk": true}]},
|
||||||
|
{"process":"Process XX","id":[5],"name":["Alfred"],"status":[{"id": "000000"}]},
|
||||||
|
{"process":"Process YY","id":[],"name":[],"status":[]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "orders all the resulting arrays according to the spread relationship ordering columns even if they aren't selected" $
|
||||||
|
get "/supervisors?select=supervisor:name,...processes(name)&processes.order=category_id.asc,name.desc&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"supervisor":"Jane","name":[]},
|
||||||
|
{"supervisor":"John","name":["Process B2", "Process A2"]},
|
||||||
|
{"supervisor":"Mary","name":["Process B2", "Process A1"]},
|
||||||
|
{"supervisor":"Peter","name":["Process B1", "Process C2", "Process C1"]},
|
||||||
|
{"supervisor":"Sarah","name":["Process B1"]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
it "orders all the resulting arrays according to the related ordering columns in the spread relationship" $
|
||||||
|
get "/supervisors?select=supervisor:name,...processes(name,...process_costs(cost))&processes.order=process_costs(cost)&order=name" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"supervisor":"Jane","name":[],"cost":[]},
|
||||||
|
{"supervisor":"John","name":["Process B2", "Process A2"],"cost":[70.00, 200.00]},
|
||||||
|
{"supervisor":"Mary","name":["Process B2", "Process A1"],"cost":[70.00, 150.00]},
|
||||||
|
{"supervisor":"Peter","name":["Process C1", "Process C2", "Process B1"],"cost":[40.00, 70.00, 180.00]},
|
||||||
|
{"supervisor":"Sarah","name":["Process B1"],"cost":[180.00]}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+34
-1
@@ -883,19 +883,24 @@ INSERT INTO processes VALUES (3, 'Process B1', 2, 1);
|
|||||||
INSERT INTO processes VALUES (4, 'Process B2', 2, 1);
|
INSERT INTO processes VALUES (4, 'Process B2', 2, 1);
|
||||||
INSERT INTO processes VALUES (5, 'Process C1', 3, 2);
|
INSERT INTO processes VALUES (5, 'Process C1', 3, 2);
|
||||||
INSERT INTO processes VALUES (6, 'Process C2', 3, 2);
|
INSERT INTO processes VALUES (6, 'Process C2', 3, 2);
|
||||||
|
INSERT INTO processes VALUES (7, 'Process XX', 3, 2);
|
||||||
|
INSERT INTO processes VALUES (8, 'Process YY', 3, 2);
|
||||||
|
|
||||||
TRUNCATE TABLE process_costs CASCADE;
|
TRUNCATE TABLE process_costs CASCADE;
|
||||||
INSERT INTO process_costs VALUES (1, 150.00);
|
INSERT INTO process_costs VALUES (1, 150.00);
|
||||||
INSERT INTO process_costs VALUES (2, 200.00);
|
INSERT INTO process_costs VALUES (2, 200.00);
|
||||||
INSERT INTO process_costs VALUES (3, 180.00);
|
INSERT INTO process_costs VALUES (3, 180.00);
|
||||||
INSERT INTO process_costs VALUES (4, 70.00);
|
INSERT INTO process_costs VALUES (4, 70.00);
|
||||||
INSERT INTO process_costs VALUES (5, 120.00);
|
INSERT INTO process_costs VALUES (5, 40.00);
|
||||||
|
INSERT INTO process_costs VALUES (6, 70.00);
|
||||||
|
INSERT INTO process_costs VALUES (8, 40.00);
|
||||||
|
|
||||||
TRUNCATE TABLE supervisors CASCADE;
|
TRUNCATE TABLE supervisors CASCADE;
|
||||||
INSERT INTO supervisors VALUES (1, 'Mary');
|
INSERT INTO supervisors VALUES (1, 'Mary');
|
||||||
INSERT INTO supervisors VALUES (2, 'John');
|
INSERT INTO supervisors VALUES (2, 'John');
|
||||||
INSERT INTO supervisors VALUES (3, 'Peter');
|
INSERT INTO supervisors VALUES (3, 'Peter');
|
||||||
INSERT INTO supervisors VALUES (4, 'Sarah');
|
INSERT INTO supervisors VALUES (4, 'Sarah');
|
||||||
|
INSERT INTO supervisors VALUES (5, 'Jane');
|
||||||
|
|
||||||
TRUNCATE TABLE process_supervisor CASCADE;
|
TRUNCATE TABLE process_supervisor CASCADE;
|
||||||
INSERT INTO process_supervisor VALUES (1, 1);
|
INSERT INTO process_supervisor VALUES (1, 1);
|
||||||
@@ -907,6 +912,34 @@ INSERT INTO process_supervisor VALUES (4, 2);
|
|||||||
INSERT INTO process_supervisor VALUES (5, 3);
|
INSERT INTO process_supervisor VALUES (5, 3);
|
||||||
INSERT INTO process_supervisor VALUES (6, 3);
|
INSERT INTO process_supervisor VALUES (6, 3);
|
||||||
|
|
||||||
|
TRUNCATE TABLE operators CASCADE;
|
||||||
|
INSERT INTO operators VALUES (1, 'Anne', '{"id": "543210", "afk": true}');
|
||||||
|
INSERT INTO operators VALUES (2, 'Louis', '{"id": "012345"}');
|
||||||
|
INSERT INTO operators VALUES (3, 'Jeff', '{"id": "666666", "afk": true}');
|
||||||
|
INSERT INTO operators VALUES (4, 'Liz', '{"id": "999999"}');
|
||||||
|
INSERT INTO operators VALUES (5, 'Alfred', '{"id": "000000"}');
|
||||||
|
|
||||||
|
TRUNCATE TABLE process_operator CASCADE;
|
||||||
|
INSERT INTO process_operator VALUES (1,1);
|
||||||
|
INSERT INTO process_operator VALUES (1,2);
|
||||||
|
INSERT INTO process_operator VALUES (2,1);
|
||||||
|
INSERT INTO process_operator VALUES (2,2);
|
||||||
|
INSERT INTO process_operator VALUES (2,3);
|
||||||
|
INSERT INTO process_operator VALUES (3,3);
|
||||||
|
INSERT INTO process_operator VALUES (4,1);
|
||||||
|
INSERT INTO process_operator VALUES (4,3);
|
||||||
|
INSERT INTO process_operator VALUES (6,3);
|
||||||
|
INSERT INTO process_operator VALUES (6,5);
|
||||||
|
INSERT INTO process_operator VALUES (7,5);
|
||||||
|
|
||||||
|
TRUNCATE TABLE factory_buildings CASCADE;
|
||||||
|
INSERT INTO factory_buildings VALUES (1, 'A001', 150, 'A', 1, '{"ins": "2024C", "pending": true}');
|
||||||
|
INSERT INTO factory_buildings VALUES (2, 'A002', 200, 'A', 1, '{"ins": "2025A", "pending": true}');
|
||||||
|
INSERT INTO factory_buildings VALUES (3, 'B001', 50, 'B', 2, '{"ins": "2025A", "pending": true}');
|
||||||
|
INSERT INTO factory_buildings VALUES (4, 'B002', 120, 'C', 2, '{"ins": "2023A"}');
|
||||||
|
INSERT INTO factory_buildings VALUES (5, 'C001', 240, 'B', 3, '{"ins": "2022B"}' );
|
||||||
|
INSERT INTO factory_buildings VALUES (6, 'D001', 310, 'A', 4, '{"ins": "2024C", "pending": true}');
|
||||||
|
|
||||||
TRUNCATE TABLE surr_serial_upsert CASCADE;
|
TRUNCATE TABLE surr_serial_upsert CASCADE;
|
||||||
INSERT INTO surr_serial_upsert(name, extra) VALUES ('value', 'existing value');
|
INSERT INTO surr_serial_upsert(name, extra) VALUES ('value', 'existing value');
|
||||||
|
|
||||||
|
|||||||
Vendored
+21
@@ -3779,3 +3779,24 @@ CREATE TABLE test.albums (
|
|||||||
ON UPDATE CASCADE
|
ON UPDATE CASCADE
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
create table operators (
|
||||||
|
id int primary key,
|
||||||
|
name text,
|
||||||
|
status jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
create table process_operator (
|
||||||
|
process_id int references processes(id),
|
||||||
|
operator_id int references operators(id),
|
||||||
|
primary key (process_id, operator_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table factory_buildings (
|
||||||
|
id int primary key,
|
||||||
|
code char(4),
|
||||||
|
size numeric,
|
||||||
|
"type" char(1),
|
||||||
|
factory_id int references factories(id),
|
||||||
|
inspections jsonb
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user