diff --git a/build.sbt b/build.sbt index e3012b649..ba919023a 100644 --- a/build.sbt +++ b/build.sbt @@ -5,7 +5,7 @@ val clueVersion = "0.23.1" val declineVersion = "2.3.1" val disciplineMunitVersion = "1.0.9" val flywayVersion = "9.8.1" -val grackleVersion = "0.8.2" +val grackleVersion = "0.8.1-2-49a9931-20221117T153900Z-SNAPSHOT" val http4sBlazeVersion = "0.23.12" val http4sEmberVersion = "0.23.16" val http4sJdkHttpClientVersion = "0.7.0" @@ -13,7 +13,7 @@ val jwtVersion = "5.0.0" val logbackVersion = "1.4.4" val log4catsVersion = "2.5.0" val lucumaCoreVersion = "0.57.0" -val lucumaGraphQLRoutesVersion = "0.5.5" +val lucumaGraphQLRoutesVersion = "0.5.5-13-018bb63-20221117T150008Z-SNAPSHOT" val munitVersion = "0.7.29" val munitCatsEffectVersion = "1.0.7" val munitDisciplineVersion = "1.0.9" diff --git a/modules/service/src/main/resources/db/migration/V0003__functions.sql b/modules/service/src/main/resources/db/migration/V0003__functions.sql new file mode 100644 index 000000000..380928e61 --- /dev/null +++ b/modules/service/src/main/resources/db/migration/V0003__functions.sql @@ -0,0 +1,3 @@ +-- https://stackoverflow.com/questions/54372666/create-an-immutable-clone-of-concat-ws/54384767#54384767 +CREATE OR REPLACE FUNCTION immutable_concat_ws(text, VARIADIC text[]) +RETURNS text AS 'text_concat_ws' LANGUAGE internal IMMUTABLE PARALLEL SAFE; diff --git a/modules/service/src/main/resources/db/migration/V0030__observations.sql b/modules/service/src/main/resources/db/migration/V0030__observations.sql index cf8c8fd69..bd81c7ed6 100644 --- a/modules/service/src/main/resources/db/migration/V0030__observations.sql +++ b/modules/service/src/main/resources/db/migration/V0030__observations.sql @@ -171,6 +171,22 @@ create table t_observation ( c_hour_angle_min d_hour_angle null default null, c_hour_angle_max d_hour_angle null default null, + -- a column we can use to identify and join with distinct observing conditions in this program + c_conditions_key text not null generated always as ( + immutable_concat_ws( + ',', + c_program_id, + c_cloud_extinction, + c_image_quality, + c_sky_background, + c_water_vapor, + coalesce(c_air_mass_min::text, 'null'), + coalesce(c_air_mass_max::text, 'null'), + coalesce(c_hour_angle_min::text, 'null'), + coalesce(c_hour_angle_max::text, 'null') + ) + ) stored, + -- observing conditions: both air mass fields are defined or neither are defined constraint air_mass_neither_or_both check (num_nulls(c_air_mass_min, c_air_mass_max) <> 1), @@ -217,6 +233,8 @@ create table t_observation ( ); comment on table t_observation is 'Observations.'; +create index on t_observation (c_conditions_key); + create view v_observation as select *, case when c_explicit_ra is not null then c_observation_id end as c_explicit_base_id, diff --git a/modules/service/src/main/resources/db/migration/V0100_constraint_set_groups.sql b/modules/service/src/main/resources/db/migration/V0100_constraint_set_groups.sql new file mode 100644 index 000000000..36b5f5513 --- /dev/null +++ b/modules/service/src/main/resources/db/migration/V0100_constraint_set_groups.sql @@ -0,0 +1,11 @@ +CREATE view v_constraint_set_group AS +SELECT + DISTINCT c_conditions_key, + c_program_id, + max(c_observation_id) as c_observation_id -- arbitrary, just pick one +FROM + t_observation +GROUP BY + c_conditions_key, + c_program_id; + diff --git a/modules/service/src/main/resources/lucuma/odb/graphql/OdbSchema.graphql b/modules/service/src/main/resources/lucuma/odb/graphql/OdbSchema.graphql index 1a2746dba..a3929781a 100644 --- a/modules/service/src/main/resources/lucuma/odb/graphql/OdbSchema.graphql +++ b/modules/service/src/main/resources/lucuma/odb/graphql/OdbSchema.graphql @@ -4695,6 +4695,9 @@ type Query { # Selects the first `LIMIT` matching observations based on the provided `WHERE` parameter, if any. observations( + + programId: ProgramId + # Filters the selection of observations. WHERE: WhereObservation diff --git a/modules/service/src/main/scala/lucuma/odb/Main.scala b/modules/service/src/main/scala/lucuma/odb/Main.scala index 9491fb69f..a4f40f120 100644 --- a/modules/service/src/main/scala/lucuma/odb/Main.scala +++ b/modules/service/src/main/scala/lucuma/odb/Main.scala @@ -116,10 +116,9 @@ object Main extends IOApp { for { pool <- databasePoolResource[F](databaseConfig) ssoClient <- ssoClientResource - // channels <- OdbMapping.Channels(pool) userSvc <- pool.map(UserService.fromSession(_)) middleware <- Resource.eval(ServerMiddleware(domain, ssoClient, userSvc)) - routes <- GraphQLRoutes(ssoClient, pool, SkunkMonitor.noopMonitor[F], GraphQLServiceTTL) + routes <- GraphQLRoutes(ssoClient, pool, SkunkMonitor.noopMonitor[F], GraphQLServiceTTL, userSvc) } yield { wsb => middleware(routes(wsb)) } diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/BaseMapping.scala b/modules/service/src/main/scala/lucuma/odb/graphql/BaseMapping.scala index 40e7d5caf..9e3371e37 100644 --- a/modules/service/src/main/scala/lucuma/odb/graphql/BaseMapping.scala +++ b/modules/service/src/main/scala/lucuma/odb/graphql/BaseMapping.scala @@ -22,6 +22,8 @@ trait BaseMapping[F[_]] lazy val ClassicalType = schema.ref("Classical") lazy val CloudExtinctionType = schema.ref("CloudExtinction") lazy val ConstraintSetType = schema.ref("ConstraintSet") + lazy val ConstraintSetGroupType = schema.ref("ConstraintSetGroup") + lazy val ConstraintSetGroupSelectResultType = schema.ref("ConstraintSetGroupSelectResult") lazy val CoordinatesType = schema.ref("Coordinates") lazy val CreateObservationResultType = schema.ref("CreateObservationResult") lazy val CreateProgramResultType = schema.ref("CreateProgramResult") diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/GraphQLRoutes.scala b/modules/service/src/main/scala/lucuma/odb/graphql/GraphQLRoutes.scala index 312ea5738..341121ef2 100644 --- a/modules/service/src/main/scala/lucuma/odb/graphql/GraphQLRoutes.scala +++ b/modules/service/src/main/scala/lucuma/odb/graphql/GraphQLRoutes.scala @@ -22,6 +22,7 @@ import org.typelevel.log4cats.Logger import skunk.Session import scala.concurrent.duration._ +import lucuma.odb.service.UserService object GraphQLRoutes { @@ -37,6 +38,7 @@ object GraphQLRoutes { pool: Resource[F, Session[F]], monitor: SkunkMonitor[F], ttl: FiniteDuration, + userSvc: UserService[F], ): Resource[F, WebSocketBuilder2[F] => HttpRoutes[F]] = OdbMapping.Topics(pool).flatMap { topics => Cache.timed[F, Authorization, Option[GraphQLService[F]]](ttl).map { cache => wsb => @@ -52,6 +54,9 @@ object GraphQLRoutes { { for { user <- OptionT(client.get(a)) + // If the user has never hit the ODB using http then there will be no user + // entry in the database. So go ahead and [re]canonicalize here to be sure. + _ <- OptionT.liftF(userSvc.canonicalizeUser(user)) map <- OptionT.liftF(OdbMapping(pool, monitor, user, topics)) svc = new GrackleGraphQLService(map) } yield svc diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/OdbMapping.scala b/modules/service/src/main/scala/lucuma/odb/graphql/OdbMapping.scala index cb1ee0d46..ef969b73f 100644 --- a/modules/service/src/main/scala/lucuma/odb/graphql/OdbMapping.scala +++ b/modules/service/src/main/scala/lucuma/odb/graphql/OdbMapping.scala @@ -79,6 +79,8 @@ object OdbMapping { with AllocationMapping[F] with AngleMapping[F] with CatalogInfoMapping[F] + with ConstraintSetGroupMapping[F] + with ConstraintSetGroupSelectResultMapping[F] with ConstraintSetMapping[F] with CoordinatesMapping[F] with CreateObservationResultMapping[F] @@ -160,6 +162,8 @@ object OdbMapping { AllocationMapping, AngleMapping, CatalogInfoMapping, + ConstraintSetGroupMapping, + ConstraintSetGroupSelectResultMapping, ConstraintSetMapping, CoordinatesMapping, CreateObservationResultMapping, @@ -214,6 +218,7 @@ object OdbMapping { override val selectElaborator: SelectElaborator = SelectElaborator( List( + ConstraintSetGroupElaborator, MutationElaborator, ProgramElaborator, SubscriptionElaborator, diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ConstraintSetGroupMapping.scala b/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ConstraintSetGroupMapping.scala new file mode 100644 index 000000000..a0ba53746 --- /dev/null +++ b/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ConstraintSetGroupMapping.scala @@ -0,0 +1,72 @@ +// Copyright (c) 2016-2022 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package lucuma.odb.graphql + +package mapping + +import cats.syntax.all._ +import edu.gemini.grackle.skunk.SkunkMapping +import edu.gemini.grackle.Mapping +import edu.gemini.grackle.Path +import edu.gemini.grackle.Predicate +import edu.gemini.grackle.Predicate._ +import edu.gemini.grackle.Query +import edu.gemini.grackle.Query._ +import edu.gemini.grackle.Result +import edu.gemini.grackle.TypeRef +import edu.gemini.grackle.skunk.SkunkMapping +import lucuma.core.model.Observation +import lucuma.core.model.Program +import lucuma.core.model.User +import lucuma.odb.data.Existence +import lucuma.odb.graphql.predicate.Predicates +import table.ObservationView +import lucuma.odb.graphql.table.ConstraintSetGroupView +import binding._ +import input._ +import table._ + +trait ConstraintSetGroupMapping[F[_]] + extends ConstraintSetGroupView[F] + with ObservationView[F] + with Predicates[F] { + + lazy val ConstraintSetGroupMapping = + ObjectMapping( + tpe = ConstraintSetGroupType, + fieldMappings = List( + SqlField("key", ConstraintSetGroupView.ConstraintSetKey, key = true, hidden = true), + SqlField("programId", ConstraintSetGroupView.ProgramId), + SqlObject("observations", Join(ConstraintSetGroupView.ConstraintSetKey, ObservationView.ConstraintSet.Key)), + SqlObject("constraintSet", Join(ConstraintSetGroupView.ObservationId, ObservationView.Id)), + ) + ) + + lazy val ConstraintSetGroupElaborator: Map[TypeRef, PartialFunction[Select, Result[Query]]] = + Map( + ConstraintSetGroupType -> { + case Select("observations", List( + BooleanBinding("includeDeleted", rIncludeDeleted), + ObservationIdBinding.Option("OFFSET", rOFFSET), + NonNegIntBinding.Option("LIMIT", rLIMIT), + ), child) => + (rIncludeDeleted, rOFFSET, rLIMIT).parTupled.flatMap { (includeDeleted, OFFSET, lim) => + val limit = lim.fold(ResultMapping.MaxLimit)(_.value) + ResultMapping.selectResult("observations", child, limit) { q => + FilterOrderByOffsetLimit( + pred = Some(and(List( + Predicates.observation.existence.includeDeleted(includeDeleted), + OFFSET.fold[Predicate](True)(Predicates.observation.id.gtEql) + ))), + oss = Some(List(OrderSelection[Observation.Id](ObservationType / "id", true, true))), + offset = None, + limit = Some(limit + 1), + q + ) + } + } + } + ) +} + diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ConstraintSetGroupSelectResultMapping.scala b/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ConstraintSetGroupSelectResultMapping.scala new file mode 100644 index 000000000..e58c10e35 --- /dev/null +++ b/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ConstraintSetGroupSelectResultMapping.scala @@ -0,0 +1,18 @@ +// Copyright (c) 2016-2022 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package lucuma.odb.graphql + +package mapping + +import edu.gemini.grackle.skunk.SkunkMapping + +import table.ObservationView +import lucuma.odb.graphql.table.ConstraintSetGroupView + +trait ConstraintSetGroupSelectResultMapping[F[_]] extends ResultMapping[F] { + + lazy val ConstraintSetGroupSelectResultMapping: ObjectMapping = + topLevelSelectResultMapping(ConstraintSetGroupSelectResultType) + +} diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ObservationSelectResultMapping.scala b/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ObservationSelectResultMapping.scala index 1caff6ef2..f9ee354a1 100644 --- a/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ObservationSelectResultMapping.scala +++ b/modules/service/src/main/scala/lucuma/odb/graphql/mapping/ObservationSelectResultMapping.scala @@ -11,9 +11,10 @@ import lucuma.odb.graphql.table.ProgramTable import skunk.codec.numeric.int8 import scala.tools.util.PathResolver.Environment +import lucuma.odb.graphql.table.ConstraintSetGroupView trait ObservationSelectResultMapping[F[_]] - extends ObservationView[F] with ProgramTable[F] with ResultMapping[F] { + extends ConstraintSetGroupView[F] with ObservationView[F] with ProgramTable[F] with ResultMapping[F] { lazy val ObservationSelectResultMapping: TypeMapping = SwitchMapping( @@ -21,6 +22,7 @@ trait ObservationSelectResultMapping[F[_]] List( (QueryType, "observations", topLevelSelectResultMapping(ObservationSelectResultType)), (ProgramType, "observations", nestedSelectResultMapping(ObservationSelectResultType, ProgramTable.Id, Join(ProgramTable.Id, ObservationView.ProgramId))), + (ConstraintSetGroupType, "observations", nestedSelectResultMapping(ObservationSelectResultType, ConstraintSetGroupView.ConstraintSetKey, Join(ConstraintSetGroupView.ConstraintSetKey, ObservationView.ConstraintSet.Key))), ) ) diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/mapping/QueryMapping.scala b/modules/service/src/main/scala/lucuma/odb/graphql/mapping/QueryMapping.scala index 5f25a4133..55113b73f 100644 --- a/modules/service/src/main/scala/lucuma/odb/graphql/mapping/QueryMapping.scala +++ b/modules/service/src/main/scala/lucuma/odb/graphql/mapping/QueryMapping.scala @@ -43,6 +43,7 @@ trait QueryMapping[F[_]] extends Predicates[F] { ObjectMapping( tpe = QueryType, fieldMappings = List( + SqlObject("constraintSetGroup"), SqlObject("filterTypeMeta"), SqlObject("observation"), SqlObject("observations"), @@ -56,6 +57,7 @@ trait QueryMapping[F[_]] extends Predicates[F] { lazy val QueryElaborator: Map[TypeRef, PartialFunction[Select, Result[Query]]] = List( + ConstraintSetGroup, FilterTypeMeta, Observation, Observations, @@ -70,6 +72,35 @@ trait QueryMapping[F[_]] extends Predicates[F] { // Elaborators below + private lazy val ConstraintSetGroup: PartialFunction[Select, Result[Query]] = { + case Select("constraintSetGroup", List( + ProgramIdBinding("programId", rProgramId), + _, // TODO: WHERE + NonNegIntBinding.Option("LIMIT", rLIMIT), + BooleanBinding("includeDeleted", rIncludeDeleted) + ), child) => + (rProgramId, rLIMIT, rIncludeDeleted).parTupled.flatMap { (pid, LIMIT, includeDeleted) => + val limit = LIMIT.foldLeft(ResultMapping.MaxLimit)(_ min _.value) + ResultMapping.selectResult("constraintSetGroup", child, limit) { q => + FilterOrderByOffsetLimit( + pred = Some( + and(List( + Predicates.constraintSetGroup.programId.eql(pid), + // Predicates.constraintSetGroup.programId.existence.includeDeleted(includeDeleted), + // Predicates.constraintSetGroup.programId.isVisibleTo(user), + )) + ), + oss = Some(List( + OrderSelection[String](ConstraintSetGroupType / "key") + )), + offset = None, + limit = Some(limit + 1), // Select one extra row here. + child = q + ) + } + } + } + private lazy val FilterTypeMeta: PartialFunction[Select, Result[Query]] = case Select("filterTypeMeta", Nil, child) => Result(Select("filterTypeMeta", Nil, @@ -98,16 +129,18 @@ trait QueryMapping[F[_]] extends Predicates[F] { val WhereObservationBinding = WhereObservation.binding(Path.from(ObservationType)) { case Select("observations", List( + ProgramIdBinding.Option("programId", rPid), WhereObservationBinding.Option("WHERE", rWHERE), ObservationIdBinding.Option("OFFSET", rOFFSET), NonNegIntBinding.Option("LIMIT", rLIMIT), BooleanBinding("includeDeleted", rIncludeDeleted) ), child) => - (rWHERE, rOFFSET, rLIMIT, rIncludeDeleted).parTupled.flatMap { (WHERE, OFFSET, LIMIT, includeDeleted) => + (rPid, rWHERE, rOFFSET, rLIMIT, rIncludeDeleted).parTupled.flatMap { (pid, WHERE, OFFSET, LIMIT, includeDeleted) => val limit = LIMIT.foldLeft(ResultMapping.MaxLimit)(_ min _.value) ResultMapping.selectResult("observations", child, limit) { q => FilterOrderByOffsetLimit( pred = Some(and(List( + pid.map(Predicates.observation.program.id.eql).getOrElse(True), OFFSET.map(Predicates.observation.id.gtEql).getOrElse(True), Predicates.observation.existence.includeDeleted(includeDeleted), Predicates.observation.program.isVisibleTo(user), diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/predicate/ConstraintSetGroupPredicates.scala b/modules/service/src/main/scala/lucuma/odb/graphql/predicate/ConstraintSetGroupPredicates.scala new file mode 100644 index 000000000..50123a70b --- /dev/null +++ b/modules/service/src/main/scala/lucuma/odb/graphql/predicate/ConstraintSetGroupPredicates.scala @@ -0,0 +1,11 @@ +// Copyright (c) 2016-2022 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package lucuma.odb.graphql.predicate + +import edu.gemini.grackle.Path +import lucuma.core.model.Program + +class ConstraintSetGroupPredicates(path: Path) { + val programId = LeafPredicates[Program.Id](path / "programId") +} diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/predicate/Predicates.scala b/modules/service/src/main/scala/lucuma/odb/graphql/predicate/Predicates.scala index 43bbec421..afc17c656 100644 --- a/modules/service/src/main/scala/lucuma/odb/graphql/predicate/Predicates.scala +++ b/modules/service/src/main/scala/lucuma/odb/graphql/predicate/Predicates.scala @@ -22,6 +22,7 @@ trait Predicates[F[_]] extends BaseMapping[F] { val proposalClass = ProposalClassPredicates(Path.from(ProposalClassType)) val setAllocationResult = SetAllocationResultPredicates(Path.from(SetAllocationResultType)) val target = TargetPredicates(Path.from(TargetType)) + val constraintSetGroup = ConstraintSetGroupPredicates(Path.from(ConstraintSetGroupType)) } } \ No newline at end of file diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/table/ConstraintSetGroupView.scala b/modules/service/src/main/scala/lucuma/odb/graphql/table/ConstraintSetGroupView.scala new file mode 100644 index 000000000..6f720bf69 --- /dev/null +++ b/modules/service/src/main/scala/lucuma/odb/graphql/table/ConstraintSetGroupView.scala @@ -0,0 +1,20 @@ +// Copyright (c) 2016-2022 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package lucuma.odb.graphql + +package table + +import edu.gemini.grackle.skunk.SkunkMapping +import lucuma.odb.util.Codecs._ +import skunk.codec.all._ + +trait ConstraintSetGroupView[F[_]] extends BaseMapping[F] { + + object ConstraintSetGroupView extends TableDef("v_constraint_set_group") { + val ProgramId: ColumnRef = col("c_program_id", program_id) + val ConstraintSetKey: ColumnRef = col("c_conditions_key", text) + val ObservationId: ColumnRef = col("c_observation_id", observation_id) + } + +} \ No newline at end of file diff --git a/modules/service/src/main/scala/lucuma/odb/graphql/table/ObservationView.scala b/modules/service/src/main/scala/lucuma/odb/graphql/table/ObservationView.scala index 730c33cd8..e5a55a92b 100644 --- a/modules/service/src/main/scala/lucuma/odb/graphql/table/ObservationView.scala +++ b/modules/service/src/main/scala/lucuma/odb/graphql/table/ObservationView.scala @@ -34,6 +34,7 @@ trait ObservationView[F[_]] extends BaseMapping[F] { } object ConstraintSet { + val Key: ColumnRef = col("c_conditions_key", text) val CloudExtinction: ColumnRef = col("c_cloud_extinction", cloud_extinction.embedded) val ImageQuality: ColumnRef = col("c_image_quality", image_quality.embedded) val SkyBackground: ColumnRef = col("c_sky_background", sky_background.embedded)