Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CQRS/Commands/SaveWeatherCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@

public class SaveWeatherCommand : IRequest<IResult>
{
public WeatherDataModel Weather { get; set; }

Check warning on line 8 in CQRS/Commands/SaveWeatherCommand.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Weather' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 8 in CQRS/Commands/SaveWeatherCommand.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Weather' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public int CardId { get; set; }
}
60 changes: 41 additions & 19 deletions Constants/QueryStrings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,32 @@ INSERT INTO IconsConnected (CardId, IconId)
VALUES (@CardId, @IconId);
SELECT * FROM IconsConnected WHERE Id = last_insert_rowid();";

public const string InsertWeatherDataQuery = @"
INSERT INTO WeatherData (CardId, Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt)
VALUES (@CardId, @Address, @Timezone, @Description, @Temp, @FeelsLike, @Humidity, @WindSpeed, @WindDir, @FetchedAt);
SELECT * FROM RowColumn WHERE Id = last_insert_rowid();";

public const string UpsertWeatherDataQuery = @"
INSERT INTO WeatherData (Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt)
VALUES (@Address, @Timezone, @Description, @Temp, @FeelsLike, @Humidity, @WindSpeed, @WindDir, @FetchedAt)
ON CONFLICT(Address) DO UPDATE SET
Timezone = excluded.Timezone,
Description = excluded.Description,
Temp = excluded.Temp,
FeelsLike = excluded.FeelsLike,
Humidity = excluded.Humidity,
WindSpeed = excluded.WindSpeed,
WindDir = excluded.WindDir,
FetchedAt = excluded.FetchedAt
RETURNING *;";

public const string UpsertWeatherDataConnectionQuery = @"
INSERT INTO WeatherDataConnection (CardId, WeatherId)
VALUES (@CardId, @WeatherId)
ON CONFLICT(CardId) DO UPDATE SET
WeatherId = excluded.WeatherId
RETURNING *;";

public const string DeleteOrphanedWeatherDataQuery = @"
DELETE FROM WeatherData
WHERE Id = @Id
AND NOT EXISTS (SELECT 1 FROM WeatherDataConnection WHERE WeatherId = @Id);";

public const string SelectCardQuery = @"
SELECT
co.Id AS CardId,
Expand Down Expand Up @@ -141,23 +162,22 @@ UPDATE ProviderKeys
FROM ProviderKeys
WHERE Provider = @Provider;";

public const string UpdateWeatherDataQuery = @"
UPDATE WeatherData
SET Address = @Address,
Timezone = @Timezone,
Description = @Description,
Temp = @Temp,
FeelsLike = @FeelsLike,
Humidity = @Humidity,
WindSpeed = @WindSpeed,
WindDir = @WindDir,
FetchedAt = @FetchedAt
WHERE CardId = @CardId;";

public const string SelectWeatherDataQuery = @"
SELECT Id, CardId, Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt
SELECT Id, Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt
FROM WeatherData /**where**/";

public const string SelectAllWeatherDataQuery = @"
SELECT wc.CardId, w.Id AS WeatherId, w.Address, w.Timezone, w.Description,
w.Temp, w.FeelsLike, w.Humidity, w.WindSpeed, w.WindDir, w.FetchedAt
FROM WeatherDataConnection wc
INNER JOIN WeatherData w ON w.Id = wc.WeatherId /**where**/";

public const string SelectWeatherDataConnectionQuery = @"
SELECT *
FROM WeatherDataConnection wc /**where**/";

public const string DeleteFromWeatherDataConnectionQuery = "DELETE FROM WeatherDataConnection /**where**/";

public const string JoinIconDataQuery = "Icon i ON i.Id = ic.IconId";
public const string JoinIconsConnectedDataQuery = "IconsConnected ic ON ic.CardId = co.Id";
public const string JoinSettingsQuery = "Settings cs ON cs.CardId = co.Id";
Expand All @@ -166,6 +186,8 @@ UPDATE WeatherData
public const string WhereCardIdForeignKeyEqualId = "CardId = @CardId";
public const string WhereLocationEqualsLocation = "Address = @Address";
public const string WhereIdEqualsId = "Id = @Id";
public const string WhereWeatherConnectedCardIdForeignKeyEqualIdWithAlias = "wc.CardId = @CardId";
public const string WhereWeatherConnectedWeatherIdForeignKeyEqualIdWithAlias = "wc.WeatherId = @WeatherId";
public const string WhereIconConnectedIconIdForeignKeyEqualIdWithAlias = "ic.IconId = @IconId";
public const string WhereIconConnectedCardIdForeignKeyEqualIdWithAlias = "ic.CardId = @CardId";
public const string WhereCardIdEqualsCardIdWithAlias = "co.Id = @cardId";
Expand Down
95 changes: 87 additions & 8 deletions Data/DbInitializer.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Dapper;
using System.Data;
using Microsoft.Data.Sqlite;

namespace Gridly.Data;

Expand All @@ -11,7 +12,7 @@ public DbInitializer(IDbConnection connection)
{
this.connection = connection;
}

public async Task EnsureTablesCreatedAsync()
{
await connection.ExecuteAsync(
Expand Down Expand Up @@ -57,7 +58,7 @@ CREATE TABLE IF NOT EXISTS Settings(
CREATE TABLE IF NOT EXISTS WidgetType(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Name TEXT NOT NULL);

CREATE TABLE IF NOT EXISTS Widget(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
WidgetType INTEGER,
Expand All @@ -75,17 +76,25 @@ CREATE TABLE IF NOT EXISTS ProviderKeys(

CREATE TABLE IF NOT EXISTS WeatherData(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
CardId INTEGER NOT NULL,
Address TEXT NOT NULL,
Address TEXT NOT NULL UNIQUE,
Timezone TEXT NOT NULL,
Description TEXT NOT NULL,
Temp REAL NOT NULL,
FeelsLike REAL NOT NULL,
Humidity REAL NOT NULL,
WindSpeed REAL NOT NULL,
WindDir REAL NOT NULL,
FetchedAt TEXT NOT NULL,
FOREIGN KEY(CardId) REFERENCES Card(Id));
FetchedAt TEXT NOT NULL);

CREATE TABLE IF NOT EXISTS WeatherDataConnection(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
CardId INTEGER NOT NULL,
WeatherId INTEGER NOT NULL,
UNIQUE(CardId),
FOREIGN KEY(CardId) REFERENCES Card(Id) ON DELETE CASCADE,
FOREIGN KEY(WeatherId) REFERENCES WeatherData(Id) ON DELETE CASCADE);

CREATE INDEX IF NOT EXISTS idx_weatherdataconnection_weatherid ON WeatherDataConnection(WeatherId);

INSERT INTO WidgetType(Name)
SELECT 'Empty'
Expand All @@ -101,8 +110,8 @@ INSERT INTO WidgetType(Name)

INSERT INTO Widget(WidgetType, Label, Description, Icon)
SELECT Id, 'Weather widget', '', 'clouds'
FROM WidgetType
WHERE Name = 'Weather'
FROM WidgetType
WHERE Name = 'Weather'
AND NOT EXISTS (SELECT 1 FROM Widget WHERE Id = 1);

INSERT INTO Widget(WidgetType, Label, Description, Icon)
Expand All @@ -117,5 +126,75 @@ FROM WidgetType
WHERE Name = 'Custom'
AND NOT EXISTS (SELECT 1 FROM Widget WHERE Id = 3);",
commandTimeout:150);

await MigrateLegacyWeatherDataAsync();
}

private async Task MigrateLegacyWeatherDataAsync()
{
var columnNames = await connection.QueryAsync<string>(
"SELECT name FROM pragma_table_info('WeatherData');");

if (!columnNames.Contains("CardId"))
return;

BackupDatabaseFile();

await connection.ExecuteAsync(
sql: @"
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;

CREATE TABLE WeatherData_new(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Address TEXT NOT NULL UNIQUE,
Timezone TEXT NOT NULL,
Description TEXT NOT NULL,
Temp REAL NOT NULL,
FeelsLike REAL NOT NULL,
Humidity REAL NOT NULL,
WindSpeed REAL NOT NULL,
WindDir REAL NOT NULL,
FetchedAt TEXT NOT NULL);

INSERT INTO WeatherData_new (Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt)
SELECT Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt
FROM WeatherData wd
WHERE wd.Id = (
SELECT wd2.Id FROM WeatherData wd2
WHERE wd2.Address = wd.Address
ORDER BY wd2.FetchedAt DESC, wd2.Id DESC
LIMIT 1
);

INSERT INTO WeatherDataConnection (CardId, WeatherId)
SELECT legacy.CardId, wn.Id
FROM (
SELECT wd.CardId, wd.Address
FROM WeatherData wd
WHERE wd.Id = (
SELECT wd2.Id FROM WeatherData wd2
WHERE wd2.CardId = wd.CardId
ORDER BY wd2.FetchedAt DESC, wd2.Id DESC
LIMIT 1
)
) legacy
INNER JOIN WeatherData_new wn ON wn.Address = legacy.Address;

DROP TABLE WeatherData;
ALTER TABLE WeatherData_new RENAME TO WeatherData;

COMMIT;
PRAGMA foreign_keys=ON;",
commandTimeout: 150);
}

private void BackupDatabaseFile()
{
var dataSource = new SqliteConnectionStringBuilder(connection.ConnectionString).DataSource;
if (string.IsNullOrWhiteSpace(dataSource) || !File.Exists(dataSource))
return;

File.Copy(dataSource, dataSource + ".bak", overwrite: true);
}
}
8 changes: 8 additions & 0 deletions Dtos/WeatherDataConnectionDtoModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Gridly.Dtos;

public class WeatherDataConnectionDtoModel
{
public int? Id { get; set; }
public int? CardId { get; set; }
public int? WeatherId { get; set; }
}
14 changes: 14 additions & 0 deletions Factories/WeatherDataConnectionFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Gridly.Dtos;

namespace Gridly.Factories
{
public class WeatherDataConnectionFactory
{
public static WeatherDataConnectionDtoModel Create(int cardId, int weatherId)
=> new WeatherDataConnectionDtoModel
{
CardId = cardId,
WeatherId = weatherId,
};
}
}
2 changes: 1 addition & 1 deletion Gridly-Client/src/app/components/card/card.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
<span><mat-icon class="card-mat-icon-size">{{card.iconData?.materialIcon}}</mat-icon></span>
@if(storedWeatherData$ | async; as weatherData){
@for(weather of weatherData; track weather){
@if (card.id === weather.cardId && card.type === CardTypes.Weather) {
@if (card.type === CardTypes.Weather) {
<ul class="weather-section">
<li> {{ 'card.weatherSection.address' | translate }} {{weather.address}}</li>
<li> {{ 'card.weatherSection.temp' | translate }} {{weather.temp}}</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ describe('EditCardDialogFacade', () => {
};

const makeWeather = (): WeatherDataModel => ({
cardId: 0,
address: 'Stockholm, Sweden',
timezone: 'Europe/Stockholm',
description: 'clear',
Expand Down Expand Up @@ -155,17 +154,6 @@ describe('EditCardDialogFacade', () => {
expect(facade.cityInput).toBe('');
});

it('does not re-save when the returned weather already belongs to the requested card', async () => {
facade.countryInput = 'Sweden';
facade.cityInput = 'Stockholm';
weatherProviderServiceMock.getWeather.mockResolvedValue([{ ...makeWeather(), cardId: 9 }, 200]);

const result = await facade.saveLocation(9);

expect(result).toBe(false);
expect(weatherProviderServiceMock.save).not.toHaveBeenCalled();
});

it('falls back to getVisualCrossingData and saves the freshly-fetched weather when getWeather fails', async () => {
facade.countryInput = 'Sweden';
facade.cityInput = 'Stockholm';
Expand All @@ -177,7 +165,8 @@ describe('EditCardDialogFacade', () => {
expect(result).toBe(true);
expect(weatherProviderServiceMock.getVisualCrossingData).toHaveBeenCalledWith('Sweden,Stockholm');
expect(weatherProviderServiceMock.save).toHaveBeenCalledWith(
expect.objectContaining({"address": "Stockholm, Sweden", "cardId": 9, "description": "clear", "feelsLike": 20, "humidity": 50, "id": 0, "temp": 20, "timezone": "Europe/Stockholm", "windDir": 180, "windSpeed": 5})
expect.objectContaining({"address": "Stockholm, Sweden", "description": "clear", "feelsLike": 20, "humidity": 50, "id": 0, "temp": 20, "timezone": "Europe/Stockholm", "windDir": 180, "windSpeed": 5}),
9
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,8 @@ export class EditCardDialogFacade {
}
}
if (status === 200 && weather !== undefined) {
if (weather.cardId !== cardId) {
weather.cardId = cardId;
await this.#weatherProviderService.save(weather);
return true;
}
await this.#weatherProviderService.save(weather, cardId);
return true;
}
this.errorStatus = status;
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ describe('SetLocationForProviderDialogComponent', () => {

const weather: WeatherDataModel = {
id: 1,
cardId: 99,
address: 'Sweden,Stockholm',
timezone: 'Europe/Stockholm',
description: 'Clear',
Expand Down Expand Up @@ -70,30 +69,18 @@ describe('SetLocationForProviderDialogComponent', () => {
});

it('saves the weather, resets inputs, and closes on a successful lookup', async () => {
weatherProviderServiceMock.getWeather.mockResolvedValue([{ ...weather, cardId: 1 }, 200]);
weatherProviderServiceMock.getWeather.mockResolvedValue([{ ...weather }, 200]);
component.countryInput = 'Sweden';
component.cityInput = 'Stockholm';

await component.onSubmit();

expect(weatherProviderServiceMock.getWeather).toHaveBeenCalledWith('Sweden,Stockholm');
expect(weatherProviderServiceMock.save).toHaveBeenCalledWith(expect.objectContaining({ cardId: 99 }));
expect(component.countryInput).toBe('');
expect(component.cityInput).toBe('');
expect(component.close).toHaveBeenCalled();
});

it('does not re-save when the weather already belongs to the current card', async () => {
weatherProviderServiceMock.getWeather.mockResolvedValue([{ ...weather, cardId: 99 }, 200]);
component.countryInput = 'Sweden';
component.cityInput = 'Stockholm';

await component.onSubmit();

expect(weatherProviderServiceMock.save).not.toHaveBeenCalled();
expect(component.close).toHaveBeenCalled();
});

it('falls back to visual crossing and prompts for an invalid key on a 401', async () => {
weatherProviderServiceMock.getWeather.mockResolvedValue([undefined, 401]);
weatherProviderServiceMock.getVisualCrossingData.mockResolvedValue([undefined, 401]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,7 @@ export class SetLocationForProviderDialogComponent extends BaseDialogComponent{
this.countryInput = '';
this.cityInput = '';

if(weather.cardId !== this.id){
weather.cardId = this.id;
await this.#weatherProviderService.save(weather);
}
await this.#weatherProviderService.save(weather, this.id);
this.close();
}

Expand Down
12 changes: 12 additions & 0 deletions Gridly-Client/src/app/models/cardWeatherData.Model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export class CardWeatherDataModel {
cardId!:number;
weatherId!:number;
address!:string;
timezone!:string;
description!:string;
temp!:number;
feelsLike!:number;
humidity!:number;
windSpeed!:number;
windDir!:number;
}
1 change: 0 additions & 1 deletion Gridly-Client/src/app/models/weatherData.Model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export class WeatherDataModel {
id!:number;
cardId!:number;
address!:string;
timezone!:string;
description!:string;
Expand Down
Loading
Loading