feat: Adds createFromCSV to create rooms in the database, properly handling delegating airflow to another room.
Some checks failed
CI / Linux Tests (push) Failing after 5m29s

This commit is contained in:
2026-02-07 18:16:01 -05:00
parent 0134c9bfc2
commit 76bd788769
12 changed files with 171 additions and 2844 deletions

View File

@@ -95,6 +95,9 @@ let package = Package(
.target(name: "DatabaseClient"), .target(name: "DatabaseClient"),
.product(name: "DependenciesTestSupport", package: "swift-dependencies"), .product(name: "DependenciesTestSupport", package: "swift-dependencies"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
],
resources: [
.copy("Resources")
] ]
), ),
.target( .target(

View File

@@ -6841,6 +6841,13 @@
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
} }
} }
.space-y-3 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-4 { .space-y-4 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;

View File

@@ -12,7 +12,7 @@ extension DependencyValues {
@DependencyClient @DependencyClient
public struct CSVParser: Sendable { public struct CSVParser: Sendable {
public var parseRooms: @Sendable (Room.CSV) async throws -> [Room.Create] public var parseRooms: @Sendable (Room.CSV) async throws -> [Room.CSV.Row]
} }
extension CSVParser: DependencyKey { extension CSVParser: DependencyKey {
@@ -24,12 +24,11 @@ extension CSVParser: DependencyKey {
throw CSVParsingError("Unreadable file data") throw CSVParsingError("Unreadable file data")
} }
let rows = try RoomCSVParser().parse(string[...].utf8) let rows = try RoomCSVParser().parse(string[...].utf8)
let rooms = rows.reduce(into: [Room.Create]()) { return rows.reduce(into: [Room.CSV.Row]()) {
if case .room(let room) = $1 { if case .room(let room) = $1 {
$0.append(room) $0.append(room)
} }
} }
return rooms
} }
) )
} }

View File

@@ -25,7 +25,7 @@ struct RoomRowParser: Parser {
enum RoomRowType { enum RoomRowType {
case header(String) case header(String)
case room(Room.Create) case room(Room.CSV.Row)
} }
struct RoomCreateParser: ParserPrinter { struct RoomCreateParser: ParserPrinter {
@@ -34,7 +34,7 @@ struct RoomCreateParser: ParserPrinter {
// the room yet, so we will need an intermediate representation for the csv data // the room yet, so we will need an intermediate representation for the csv data
// that uses a room's name or disregard and require user to delegate airflow in // that uses a room's name or disregard and require user to delegate airflow in
// the ui. // the ui.
var body: some ParserPrinter<Substring.UTF8View, Room.Create> { var body: some ParserPrinter<Substring.UTF8View, Room.CSV.Row> {
ParsePrint { ParsePrint {
Prefix { $0 != UInt8(ascii: ",") }.map(.string) Prefix { $0 != UInt8(ascii: ",") }.map(.string)
",".utf8 ",".utf8
@@ -51,9 +51,9 @@ struct RoomCreateParser: ParserPrinter {
Int.parser() Int.parser()
",".utf8 ",".utf8
Optionally { Optionally {
Room.ID.parser() Prefix { $0 != UInt8(ascii: "\n") }.map(.string)
} }
} }
.map(.memberwise(Room.Create.init)) .map(.memberwise(Room.CSV.Row.init))
} }
} }

View File

@@ -91,6 +91,7 @@ public struct DatabaseClient: Sendable {
public struct Rooms: Sendable { public struct Rooms: Sendable {
public var create: @Sendable (Project.ID, Room.Create) async throws -> Room public var create: @Sendable (Project.ID, Room.Create) async throws -> Room
public var createMany: @Sendable (Project.ID, [Room.Create]) async throws -> [Room] public var createMany: @Sendable (Project.ID, [Room.Create]) async throws -> [Room]
public var createFromCSV: @Sendable (Project.ID, [Room.CSV.Row]) async throws -> [Room]
public var delete: @Sendable (Room.ID) async throws -> Void public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize: public var deleteRectangularSize:
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room @Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
@@ -98,6 +99,7 @@ public struct DatabaseClient: Sendable {
public var fetch: @Sendable (Project.ID) async throws -> [Room] public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
} }
@DependencyClient @DependencyClient

View File

@@ -16,11 +16,59 @@ extension DatabaseClient.Rooms: TestDependencyKey {
return try model.toDTO() return try model.toDTO()
}, },
createMany: { projectID, rooms in createMany: { projectID, rooms in
try await rooms.asyncMap { request in try await RoomModel.createMany(projectID: projectID, rooms: rooms, on: database)
let model = try request.toModel(projectID: projectID) },
try await model.validateAndSave(on: database) createFromCSV: { projectID, rows in
return try model.toDTO()
database.logger.debug("\nCreate From CSV rows: \(rows)\n")
// Filter out rows that delegate their airflow / load to another room,
// these need to be created last.
let rowsThatDelegate = rows.filter({
$0.delegatedToName != nil && $0.delegatedToName != ""
})
let initialRooms = rows.filter({
$0.delegatedToName == nil || $0.delegatedToName == ""
})
.map(\.createModel)
database.logger.debug("\nInitial rows: \(initialRooms)\n")
let initialCreated = try await RoomModel.createMany(
projectID: projectID,
rooms: initialRooms,
on: database
)
database.logger.debug("\nInitially created rows: \(initialCreated)\n")
let roomsThatDelegateModels = try rowsThatDelegate.reduce(into: [Room.Create]()) {
array, row in
database.logger.debug("\n\(row.name), delegating to: \(row.delegatedToName!)\n")
guard let created = initialCreated.first(where: { $0.name == row.delegatedToName }) else {
database.logger.debug(
"\nUnable to find created room with name: \(row.delegatedToName!)\n"
)
throw NotFoundError()
}
array.append(
Room.Create.init(
name: row.name,
heatingLoad: row.heatingLoad,
coolingTotal: row.coolingTotal,
coolingSensible: row.coolingSensible,
registerCount: 0,
delegatedTo: created.id
)
)
} }
return try await RoomModel.createMany(
projectID: projectID,
rooms: roomsThatDelegateModels,
on: database
) + initialCreated
}, },
delete: { id in delete: { id in
guard let model = try await RoomModel.find(id, on: database) else { guard let model = try await RoomModel.find(id, on: database) else {
@@ -81,6 +129,34 @@ extension DatabaseClient.Rooms: TestDependencyKey {
} }
} }
extension Room.CSV.Row {
fileprivate var createModel: Room.Create {
assert(delegatedToName == nil || delegatedToName == "")
return .init(
name: name,
heatingLoad: heatingLoad,
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
registerCount: registerCount,
delegatedTo: nil
)
}
}
extension RoomModel {
fileprivate static func createMany(
projectID: Project.ID,
rooms: [Room.Create],
on database: any Database
) async throws -> [Room] {
try await rooms.asyncMap { request in
let model = try request.toModel(projectID: projectID)
try await model.validateAndSave(on: database)
return try model.toDTO()
}
}
}
extension Room.Create { extension Room.Create {
func toModel(projectID: Project.ID) throws -> RoomModel { func toModel(projectID: Project.ID) throws -> RoomModel {

View File

@@ -148,6 +148,50 @@ extension Room {
public init(file: Data) { public init(file: Data) {
self.file = file self.file = file
} }
/// Represents a row in a CSV file.
///
/// This is similar to ``Room.Create``, but since the rooms are not yet
/// created, delegating to another room is done via the room's name
/// instead of id.
///
public struct Row: Codable, Equatable, Sendable {
/// A unique name for the room in the project.
public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int
/// An optional room that this room delegates it's airflow to.
public let delegatedToName: String?
public init(
name: String,
heatingLoad: Double,
coolingTotal: Double? = nil,
coolingSensible: Double? = nil,
registerCount: Int,
delegatedToName: String? = nil
) {
self.name = name
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
self.delegatedToName = delegatedToName
}
}
} }
/// Represents a rectangular size calculation that is stored in the /// Represents a rectangular size calculation that is stored in the

View File

@@ -293,7 +293,7 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
case .csv(let csv): case .csv(let csv):
return await roomsView(on: request, projectID: projectID) { return await roomsView(on: request, projectID: projectID) {
let rooms = try await csvParser.parseRooms(csv) let rooms = try await csvParser.parseRooms(csv)
_ = try await database.rooms.createMany(projectID, rooms) _ = try await database.rooms.createFromCSV(projectID, rooms)
} }
// return EmptyHTML() // return EmptyHTML()

View File

@@ -1,4 +1,5 @@
import App import App
import CSVParser
import DatabaseClient import DatabaseClient
import Dependencies import Dependencies
import Fluent import Fluent

View File

@@ -1,4 +1,6 @@
import CSVParser
import Dependencies import Dependencies
import FileClient
import Foundation import Foundation
import ManualDCore import ManualDCore
import Parsing import Parsing
@@ -63,6 +65,31 @@ struct RoomTests {
} }
} }
@Test
func createFromCSV() async throws {
try await withTestUserAndProject {
$0.csvParser = .liveValue
} operation: { _, project in
@Dependency(\.csvParser) var csvParser
@Dependency(\.database) var database
@Dependency(\.fileClient) var fileClient
let csvPath = Bundle.module.path(forResource: "rooms", ofType: "csv")
let csvFile = Room.CSV(file: try Data(contentsOf: URL(filePath: csvPath!)))
let rows = try await csvParser.parseRooms(csvFile)
print()
print("ROWS: \(rows)")
print()
let created = try await database.rooms.createFromCSV(project.id, rows)
print()
print("CREATED: \(created)")
print()
#expect(created.count == rows.count)
}
}
@Test @Test
func notFound() async throws { func notFound() async throws {
try await withDatabase { try await withDatabase {
@@ -157,4 +184,3 @@ struct RoomTests {
} }
} }
} }

View File

@@ -1,6 +0,0 @@
@import "tailwindcss";
@source not "./tailwindcss";
@source not "./daisyui{,*}.mjs";
@plugin "./daisyui.mjs";

2825
output.css

File diff suppressed because it is too large Load Diff