Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add views to aggregate daily allocations and usage by area #100

Merged
merged 11 commits into from
Oct 10, 2023
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
create or replace view effective_daily_consents as

with all_consents as (
select distinct source_id,
null::varchar as area_id,
null::varchar as status,
null::numeric as allocation,
null::boolean as is_metered,
null::numeric as metered_allocation_daily,
null::numeric as metered_allocation_yearly,
null::varchar[] as meters,
0 as is_real
from water_allocations
),

days_in_last_year as (
select GENERATE_SERIES(DATE_TRUNC('day', now()) - INTERVAL '1 YEAR', DATE_TRUNC('day', now()), INTERVAL '1 DAY') as effective_from
),

data_per_day as (
select * from all_consents cross join days_in_last_year
union
SELECT source_id,
area_id,
status,
allocation,
is_metered,
metered_allocation_daily,
metered_allocation_yearly,
meters,
1 as is_real,
effective_from
from water_allocations
),

latest_values as (
select source_id, effective_from, area_id, status, allocation, is_metered, metered_allocation_daily, metered_allocation_yearly, meters, is_real,
sum (case when area_id is not null then 1 end) over (partition by source_id order by effective_from, is_real) as is_area_id_null,
sum (case when status is not null then 1 end) over (partition by source_id order by effective_from, is_real) as is_status_null,
sum (case when allocation is not null then 1 end) over (partition by source_id order by effective_from, is_real) as is_allocation_null,
sum (case when is_metered is not null then 1 end) over (partition by source_id order by effective_from, is_real) as is_is_metered_null,
sum (case when metered_allocation_daily is not null then 1 end) over (partition by source_id order by effective_from, is_real) as is_mad_null,
sum (case when metered_allocation_yearly is not null then 1 end) over (partition by source_id order by effective_from, is_real) as is_may_null,
sum (case when meters is not null then 1 end) over (partition by source_id order by effective_from, is_real) as is_meters_null
from data_per_day
),

effective_daily_data as (
select source_id, effective_from,
first_value(area_id) over (partition by source_id, latest_values.is_area_id_null) as area_id,
first_value(status) over (partition by source_id, latest_values.is_status_null) as status,
first_value(allocation) over (partition by source_id, latest_values.is_allocation_null) as allocation,
first_value(is_metered) over (partition by source_id, latest_values.is_is_metered_null) as is_metered,
first_value(metered_allocation_daily) over (partition by source_id, latest_values.is_mad_null) as allocation_daily,
first_value(metered_allocation_yearly) over (partition by source_id, latest_values.is_may_null) as metered_allocation_yearly,
first_value(meters) over (partition by source_id, latest_values.is_meters_null) as meters,
ROW_NUMBER() over(order by source_id, effective_from, is_real) as row_number
from latest_values),

grouped_effective_data as (
select ea.* from effective_daily_data ea
inner join
(
select source_id, date(effective_from) as effective_from, max(row_number) as max_row_number
from effective_daily_data
group by source_id, date(effective_from)
) lef
on lef.source_id = ea.source_id
and lef.max_row_number = ea.row_number
order by source_id, effective_from
)

select * from grouped_effective_data
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
create or replace view water_allocation_and_usage_by_area as
with all_areas as
(
select distinct area_id
from effective_daily_consents
where area_id is not null
),
all_dates as (
select distinct date(effective_from) as date
from effective_daily_consents
),
area_defaults as (
select area_id, date, 0 as allocation, 0 as allocation_daily, 0 as metered_allocation_yearly, 0 as daily_usage
from all_areas cross join all_dates
),
usage as (
select area_id, date(effective_from) as date, allocation, allocation_daily, metered_allocation_yearly, case when is_metered = true then daily_usage else 0 end as daily_usage
from effective_daily_consents
inner join observed_water_use_aggregated_daily
on day_observed_at = date(effective_daily_consents.effective_from)
and site_id::varchar in(
select unnest(meters)
)
where status = 'active'
union
select * from area_defaults
)

select area_id, date, sum(allocation) as allocation, sum(allocation_daily) as allocation_daily, sum(metered_allocation_yearly) as metered_allocation_yearly, sum(daily_usage) as daily_usage
from usage
group by area_id, date
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package nz.govt.eop.plan_limits

import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.shouldBe
import java.time.LocalDate
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.transaction.annotation.Transactional

@ActiveProfiles("test")
@SpringBootTest
@Transactional
class WaterAllocationAndUsageViewsTest(@Autowired val jdbcTemplate: JdbcTemplate) {

@BeforeEach
fun resetTestData() {
truncateTestData()
createTestData()
}

@Test
fun `should be empty with no data`() {
truncateTestData()
val result =
jdbcTemplate.queryForObject(
"select count(*) from water_allocation_and_usage_by_area", Int::class.java)
result shouldBe 0
}

@Test
fun `should include a year of data`() {
val result =
jdbcTemplate.queryForMap(
"select count(*) as count, min(date) as min_date, max(date) as max_date from water_allocation_and_usage_by_area")
arrayOf(365L, 366L) shouldContain result["count"] as Long
val min = LocalDate.parse(result["min_date"].toString())
val max = LocalDate.parse(result["max_date"].toString())
min.plusYears(1) shouldBe max
}

// Pending tests
// Single consent
fun `should not include data before a consent was effective`() {}
fun `should data once a consent is active`() {}
fun `should handle changes in consent data`() {}
fun `should not include data when a consent status is not active`() {}
fun `should not include observations when is_metered is false`() {}

// Multiple consents
// Multiple meters
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smozely I've started writing some tests for the view in this style. Feels like they could quickly get verbose without some helpers for generating sets of test data. This test data needs to be dynamic, since the new views generate data relative to now.

Any thoughts on things in the Java/Kotlin/Spring ecosystem to help with this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ... not sure I have any great answers

We had the same kinda thing when trying to test complex views at ECAN, we had written a small JS layer to do similar to this style of testing, we did struggle with how to express in the code what the test data means as it was difficult to jump from "these rows here, mean this output in the view" ... not sure any suggestion here really helps with that.

via DBT we did have an a way of testing constituent views in stages, well actually it was less than perfect in that we could only test the views in stages, not the whole chain together.

Keeping it kinda how you've got there Kotlin lets you add parameters with defaults, and you can pass named parameters. Which would kinda give you the equivalent of passing an object in JS and then it being merged with a default object.

  fun createTestData(
      measurementName: String = "Water Meter Reading",
      consentAreaId: String = "area-id",
      consentSourceId: String = "source-id"
  ) {

/// STUFF

Which can then be called like

createTestData(consentSourceId = "foo", measurementName = "bar")

I think that is a good way to keep the same core test data setup but let you customize it per test

And then for the dates stuff, using the date LocalDate.now().atStartOfDay().minusDays(10) to make everything relative to now ... its kinda ugly because its hard to keep that date mangling noise down.

  • Maybe there could be the equivalent of createTestData something like createExpectedResult that would help encapsulate the logic in code, in a way that you can then use SELECT * FROM water_allocation_and_usage_by_area and compare directly to that? ... it might mean each test is doing more work, but keeps some consistency of how they flow

  • Some of this would be a bit easier if we had objects that were being returned from the view queries, just because it would be easier to create those than the Map rows to assert against. But that might be overkill if they are only going to be used for tests.


fun truncateTestData() {
jdbcTemplate.execute("truncate water_allocations cascade")
jdbcTemplate.execute("truncate observations cascade")
jdbcTemplate.execute("truncate observation_sites_measurements cascade")
jdbcTemplate.execute("truncate observation_sites cascade")
}

fun createTestData() {
val siteId = 1
val councilId = 9
val measurementId = 1
val measurementName = "Water Meter Reading"
val observationTimestamp = "2023-09-10 06:00:00+00"
val consentAreaId = "area-id"
val consentSourceId = "source-id"
val consentAllocation = 10
val consentEffectiveFrom = "2023-09-01 06:00:00+00"
jdbcTemplate.update(
"""INSERT INTO observation_sites (id, council_id, name) VALUES ($siteId, $councilId, 'Test site')""")
jdbcTemplate.update(
"""
INSERT INTO observation_sites_measurements (id, site_id, measurement_name, first_observation_at, last_observation_at, observation_count)
VALUES ($measurementId, $siteId, '$measurementName', now(), now(), 0)"""
.trimIndent())
jdbcTemplate.update(
"""INSERT INTO observations (observation_measurement_id, amount, observed_at) VALUES ($measurementId, 1, '$observationTimestamp')""")
jdbcTemplate.update(
"""
INSERT INTO water_allocations (area_id, allocation, ingest_id, source_id, consent_id, status, is_metered, metered_allocation_daily, metered_allocation_yearly, meters, effective_from, effective_to, created_at, updated_at)
VALUES ('$consentAreaId', '$consentAllocation', 'ingest-id', '$consentSourceId', 'consent-id', 'active', true, 10, 100, '{$siteId}', '$consentEffectiveFrom', null, now(), now())"""
.trimIndent())
}
}