feat: Rename items in database client for consistency.
All checks were successful
CI / Linux Tests (push) Successful in 5m24s

This commit is contained in:
2026-01-29 15:47:24 -05:00
parent 6723f7a410
commit 18a5ef06d3
17 changed files with 144 additions and 341 deletions

View File

@@ -0,0 +1,172 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient.ComponentLosses: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.ComponentLosses {
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await ComponentLossModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { projectID in
try await ComponentLossModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.all()
.map { try $0.toDTO() }
},
get: { id in
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
try updates.validate()
guard let model = try await ComponentLossModel.find(id, on: database) else {
throw NotFoundError()
}
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
}
extension ComponentPressureLoss.Create {
func toModel() throws(ValidationError) -> ComponentLossModel {
try validate()
return .init(name: name, value: value, projectID: projectID)
}
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Component loss name should not be empty.")
}
guard value > 0 else {
throw ValidationError("Component loss value should be greater than 0.")
}
guard value < 1.0 else {
throw ValidationError("Component loss value should be less than 1.0.")
}
}
}
extension ComponentPressureLoss.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Component loss name should not be empty.")
}
}
if let value {
guard value > 0 else {
throw ValidationError("Component loss value should be greater than 0.")
}
guard value < 1.0 else {
throw ValidationError("Component loss value should be less than 1.0.")
}
}
}
}
extension ComponentPressureLoss {
struct Migrate: AsyncMigration {
let name = "CreateComponentLoss"
func prepare(on database: any Database) async throws {
try await database.schema(ComponentLossModel.schema)
.id()
.field("name", .string, .required)
.field("value", .double, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
// .unique(on: "projectID", "name")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(ComponentLossModel.schema).delete()
}
}
}
final class ComponentLossModel: Model, @unchecked Sendable {
static let schema = "component_loss"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "value")
var value: Double
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@Parent(key: "projectID")
var project: ProjectModel
init() {}
init(
id: UUID? = nil,
name: String,
value: Double,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
) {
self.id = id
self.name = name
self.value = value
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
}
func toDTO() throws -> ComponentPressureLoss {
try .init(
id: requireID(),
projectID: $project.id,
name: name,
value: value,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: ComponentPressureLoss.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
if let value = updates.value, value != self.value {
self.value = value
}
}
}

View File

@@ -0,0 +1,190 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient.EquivalentLengths: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await EffectiveLengthModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { projectID in
try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.all()
.map { try $0.toDTO() }
},
fetchMax: { projectID in
let effectiveLengths = try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.all()
.map { try $0.toDTO() }
return .init(
supply: effectiveLengths.filter({ $0.type == .supply })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first,
return: effectiveLengths.filter({ $0.type == .return })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first
)
},
get: { id in
try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await EffectiveLengthModel.find(id, on: database) else {
throw NotFoundError()
}
try model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
}
extension EquivalentLength.Create {
func toModel() throws -> EffectiveLengthModel {
try validate()
return try .init(
name: name,
type: type.rawValue,
straightLengths: straightLengths,
groups: JSONEncoder().encode(groups),
projectID: projectID
)
}
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Effective length name can not be empty.")
}
}
}
extension EquivalentLength {
struct Migrate: AsyncMigration {
let name = "CreateEffectiveLength"
func prepare(on database: any Database) async throws {
try await database.schema(EffectiveLengthModel.schema)
.id()
.field("name", .string, .required)
.field("type", .string, .required)
.field("straightLengths", .array(of: .int))
.field("groups", .data)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID", "name", "type")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(EffectiveLengthModel.schema).delete()
}
}
}
// TODO: Add total effective length field so that we can lookup / compare which one is
// the longest for a given project.
final class EffectiveLengthModel: Model, @unchecked Sendable {
static let schema = "effective_length"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "type")
var type: String
@Field(key: "straightLengths")
var straightLengths: [Int]
@Field(key: "groups")
var groups: Data
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@Parent(key: "projectID")
var project: ProjectModel
init() {}
init(
id: UUID? = nil,
name: String,
type: String,
straightLengths: [Int],
groups: Data,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
) {
self.id = id
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
}
func toDTO() throws -> EquivalentLength {
try .init(
id: requireID(),
projectID: $project.id,
name: name,
type: .init(rawValue: type)!,
straightLengths: straightLengths,
groups: JSONDecoder().decode([EquivalentLength.FittingGroup].self, from: groups),
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: EquivalentLength.Update) throws {
if let name = updates.name, name != self.name {
self.name = name
}
if let type = updates.type, type.rawValue != self.type {
self.type = type.rawValue
}
if let straightLengths = updates.straightLengths, straightLengths != self.straightLengths {
self.straightLengths = straightLengths
}
if let groups = updates.groups {
self.groups = try JSONEncoder().encode(groups)
}
}
}

View File

@@ -0,0 +1,199 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient.Equipment: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await EquipmentModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { projectId in
guard
let model = try await EquipmentModel.query(on: database)
.filter("projectID", .equal, projectId)
.first()
else {
return nil
}
return try model.toDTO()
},
get: { id in
try await EquipmentModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await EquipmentModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
}
extension EquipmentInfo.Create {
func toModel() throws(ValidationError) -> EquipmentModel {
try validate()
return .init(
staticPressure: staticPressure,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
projectID: projectID
)
}
func validate() throws(ValidationError) {
guard staticPressure >= 0 else {
throw ValidationError("Equipment info static pressure should be greater than 0.")
}
guard staticPressure <= 1.0 else {
throw ValidationError("Equipment info static pressure should be less than 1.0.")
}
guard heatingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
guard coolingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
}
extension EquipmentInfo.Update {
var hasUpdates: Bool {
staticPressure != nil || heatingCFM != nil || coolingCFM != nil
}
func validate() throws(ValidationError) {
if let staticPressure {
guard staticPressure >= 0 else {
throw ValidationError("Equipment info static pressure should be greater than 0.")
}
}
if let heatingCFM {
guard heatingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
if let coolingCFM {
guard coolingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
}
}
extension EquipmentInfo {
struct Migrate: AsyncMigration {
let name = "CreateEquipment"
func prepare(on database: any Database) async throws {
try await database.schema(EquipmentModel.schema)
.id()
.field("staticPressure", .double, .required)
.field("heatingCFM", .int16, .required)
.field("coolingCFM", .int16, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(EquipmentModel.schema).delete()
}
}
}
final class EquipmentModel: Model, @unchecked Sendable {
static let schema = "equipment"
@ID(key: .id)
var id: UUID?
@Field(key: "staticPressure")
var staticPressure: Double
@Field(key: "heatingCFM")
var heatingCFM: Int
@Field(key: "coolingCFM")
var coolingCFM: Int
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@Parent(key: "projectID")
var project: ProjectModel
init() {}
init(
id: UUID? = nil,
staticPressure: Double,
heatingCFM: Int,
coolingCFM: Int,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
) {
self.id = id
self.staticPressure = staticPressure
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
}
func toDTO() throws -> EquipmentInfo {
try .init(
id: requireID(),
projectID: $project.id,
staticPressure: staticPressure,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: EquipmentInfo.Update) {
if let staticPressure = updates.staticPressure {
self.staticPressure = staticPressure
}
if let heatingCFM = updates.heatingCFM {
self.heatingCFM = heatingCFM
}
if let coolingCFM = updates.coolingCFM {
self.coolingCFM = coolingCFM
}
}
}

View File

@@ -0,0 +1,353 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient.Projects: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { userID, request in
let model = try request.toModel(userID: userID)
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
detail: { id in
guard
let model = try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
// TODO: Different error ??
guard let equipmentInfo = model.equipment else { return nil }
let trunks = try model.trunks.toDTO()
return try .init(
project: model.toDTO(),
componentLosses: model.componentLosses.map { try $0.toDTO() },
equipmentInfo: equipmentInfo.toDTO(),
equivalentLengths: model.equivalentLengths.map { try $0.toDTO() },
rooms: model.rooms.map { try $0.toDTO() },
trunks: trunks
)
},
get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
},
getCompletedSteps: { id in
let roomsCount = try await RoomModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.all()
var equivalentLengthsCompleted = false
if equivalentLengths.filter({ $0.type == "supply" }).first != nil,
equivalentLengths.filter({ $0.type == "return" }).first != nil
{
equivalentLengthsCompleted = true
}
let componentLosses = try await ComponentLossModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equipmentInfo = try await EquipmentModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.first()
return .init(
equipmentInfo: equipmentInfo != nil,
rooms: roomsCount > 0,
equivalentLength: equivalentLengthsCompleted,
frictionRate: componentLosses > 0
)
},
getSensibleHeatRatio: { id in
guard
let shr = try await ProjectModel.query(on: database)
.field(\.$id)
.field(\.$sensibleHeatRatio)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
return shr.sensibleHeatRatio
},
fetch: { userID, request in
try await ProjectModel.query(on: database)
.sort(\.$createdAt, .descending)
.with(\.$user)
.filter(\.$user.$id == userID)
.paginate(request)
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
}
extension Project.Create {
func toModel(userID: User.ID) throws -> ProjectModel {
try validate()
return .init(
name: name,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
userID: userID
)
}
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Project name should not be empty.")
}
guard !streetAddress.isEmpty else {
throw ValidationError("Project street address should not be empty.")
}
guard !city.isEmpty else {
throw ValidationError("Project city should not be empty.")
}
guard !state.isEmpty else {
throw ValidationError("Project state should not be empty.")
}
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
}
extension Project.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Project name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("Project street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("Project city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("Project state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
}
extension Project {
struct Migrate: AsyncMigration {
let name = "CreateProject"
func prepare(on database: any Database) async throws {
try await database.schema(ProjectModel.schema)
.id()
.field("name", .string, .required)
.field("streetAddress", .string, .required)
.field("city", .string, .required)
.field("state", .string, .required)
.field("zipCode", .string, .required)
.field("sensibleHeatRatio", .double)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("userID", .uuid, .required, .references(UserModel.schema, "id"))
.unique(on: "userID", "name")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(ProjectModel.schema).delete()
}
}
}
// The Database model.
final class ProjectModel: Model, @unchecked Sendable {
static let schema = "project"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "streetAddress")
var streetAddress: String
@Field(key: "city")
var city: String
@Field(key: "state")
var state: String
@Field(key: "zipCode")
var zipCode: String
@Field(key: "sensibleHeatRatio")
var sensibleHeatRatio: Double?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@Children(for: \.$project)
var componentLosses: [ComponentLossModel]
@OptionalChild(for: \.$project)
var equipment: EquipmentModel?
@Children(for: \.$project)
var equivalentLengths: [EffectiveLengthModel]
@Children(for: \.$project)
var rooms: [RoomModel]
@Children(for: \.$project)
var trunks: [TrunkModel]
@Parent(key: "userID")
var user: UserModel
init() {}
init(
id: UUID? = nil,
name: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
sensibleHeatRatio: Double? = nil,
userID: User.ID,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
self.id = id
self.name = name
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
$user.id = userID
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toDTO() throws -> Project {
try .init(
id: requireID(),
name: name,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
sensibleHeatRatio: sensibleHeatRatio,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: Project.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
self.streetAddress = streetAddress
}
if let city = updates.city, city != self.city {
self.city = city
}
if let state = updates.state, state != self.state {
self.state = state
}
if let zipCode = updates.zipCode, zipCode != self.zipCode {
self.zipCode = zipCode
}
if let sensibleHeatRatio = updates.sensibleHeatRatio,
sensibleHeatRatio != self.sensibleHeatRatio
{
self.sensibleHeatRatio = sensibleHeatRatio
}
}
}

View File

@@ -0,0 +1,267 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient.Rooms: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
deleteRectangularSize: { roomID, rectangularDuctID in
guard let model = try await RoomModel.find(roomID, on: database) else {
throw NotFoundError()
}
model.rectangularSizes?.removeAll {
$0.id == rectangularDuctID
}
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
},
get: { id in
try await RoomModel.find(id, on: database).map { try $0.toDTO() }
},
fetch: { projectID in
try await RoomModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.sort(\.$name, .ascending)
.all()
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
},
updateRectangularSize: { id, size in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
var rectangularSizes = model.rectangularSizes ?? []
rectangularSizes.removeAll {
$0.id == size.id
}
rectangularSizes.append(size)
model.rectangularSizes = rectangularSizes
try await model.save(on: database)
return try model.toDTO()
}
)
}
}
extension Room.Create {
func toModel() throws(ValidationError) -> RoomModel {
try validate()
return .init(
name: name,
heatingLoad: heatingLoad,
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
registerCount: registerCount,
projectID: projectID
)
}
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Room name should not be empty.")
}
guard heatingLoad >= 0 else {
throw ValidationError("Room heating load should not be less than 0.")
}
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
}
}
}
extension Room.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Room name should not be empty.")
}
}
if let heatingLoad {
guard heatingLoad >= 0 else {
throw ValidationError("Room heating load should not be less than 0.")
}
}
if let coolingTotal {
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
}
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
if let registerCount {
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
}
}
}
}
extension Room {
struct Migrate: AsyncMigration {
let name = "CreateRoom"
func prepare(on database: any Database) async throws {
try await database.schema(RoomModel.schema)
.id()
.field("name", .string, .required)
.field("heatingLoad", .double, .required)
.field("coolingTotal", .double, .required)
.field("coolingSensible", .double)
.field("registerCount", .int8, .required)
.field("rectangularSizes", .array)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID", "name")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(RoomModel.schema).delete()
}
}
}
final class RoomModel: Model, @unchecked Sendable {
static let schema = "room"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "heatingLoad")
var heatingLoad: Double
@Field(key: "coolingTotal")
var coolingTotal: Double
@Field(key: "coolingSensible")
var coolingSensible: Double?
@Field(key: "registerCount")
var registerCount: Int
@Field(key: "rectangularSizes")
var rectangularSizes: [Room.RectangularSize]?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@Parent(key: "projectID")
var project: ProjectModel
init() {}
init(
id: UUID? = nil,
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingSensible: Double? = nil,
registerCount: Int,
rectangularSizes: [Room.RectangularSize]? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
) {
self.id = id
self.name = name
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
}
func toDTO() throws -> Room {
try .init(
id: requireID(),
projectID: $project.id,
name: name,
heatingLoad: heatingLoad,
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
registerCount: registerCount,
rectangularSizes: rectangularSizes,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: Room.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
self.heatingLoad = heatingLoad
}
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
self.coolingTotal = coolingTotal
}
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
self.coolingSensible = coolingSensible
}
if let registerCount = updates.registerCount, registerCount != self.registerCount {
self.registerCount = registerCount
}
if let rectangularSizes = updates.rectangularSizes, rectangularSizes != self.rectangularSizes {
self.rectangularSizes = rectangularSizes
}
}
}

View File

@@ -0,0 +1,356 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
try request.validate()
let trunk = request.toModel()
var roomProxies = [TrunkSize.RoomProxy]()
try await trunk.save(on: database)
for (roomID, registers) in request.rooms {
guard let room = try await RoomModel.find(roomID, on: database) else {
throw NotFoundError()
}
let model = try TrunkRoomModel(
trunkID: trunk.requireID(),
roomID: room.requireID(),
registers: registers,
type: request.type
)
try await model.save(on: database)
roomProxies.append(
.init(room: try room.toDTO(), registers: registers)
)
}
return try .init(
id: trunk.requireID(),
projectID: trunk.$project.id,
type: .init(rawValue: trunk.type)!,
rooms: roomProxies
)
},
delete: { id in
guard let model = try await TrunkModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { projectID in
try await TrunkModel.query(on: database)
.with(\.$project)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$project.$id == projectID)
.all()
.toDTO()
},
get: { id in
guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id)
.first()
else {
return nil
}
return try model.toDTO()
},
update: { id, updates in
guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
try updates.validate()
try await model.applyUpdates(updates, on: database)
return try model.toDTO()
}
)
}
}
extension TrunkSize.Create {
func validate() throws(ValidationError) {
guard rooms.count > 0 else {
throw ValidationError("Trunk size should have associated rooms / registers.")
}
if let height {
guard height > 0 else {
throw ValidationError("Trunk size height should be greater than 0.")
}
}
}
func toModel() -> TrunkModel {
.init(
projectID: projectID,
type: type,
height: height,
name: name
)
}
}
extension TrunkSize.Update {
func validate() throws(ValidationError) {
if let rooms {
guard rooms.count > 0 else {
throw ValidationError("Trunk size should have associated rooms / registers.")
}
}
if let height {
guard height > 0 else {
throw ValidationError("Trunk size height should be greater than 0.")
}
}
}
}
extension TrunkSize {
struct Migrate: AsyncMigration {
let name = "CreateTrunkSize"
func prepare(on database: any Database) async throws {
try await database.schema(TrunkModel.schema)
.id()
.field("height", .int8)
.field("name", .string)
.field("type", .string, .required)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.create()
try await database.schema(TrunkRoomModel.schema)
.id()
.field("registers", .array(of: .int), .required)
.field("type", .string, .required)
.field(
"trunkID", .uuid, .required, .references(TrunkModel.schema, "id", onDelete: .cascade)
)
.field(
"roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade)
)
.unique(on: "trunkID", "roomID", "type")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(TrunkRoomModel.schema).delete()
try await database.schema(TrunkModel.schema).delete()
}
}
}
// Pivot table for associating rooms and trunks.
final class TrunkRoomModel: Model, @unchecked Sendable {
static let schema = "room+trunk"
@ID(key: .id)
var id: UUID?
@Parent(key: "trunkID")
var trunk: TrunkModel
@Parent(key: "roomID")
var room: RoomModel
@Field(key: "registers")
var registers: [Int]
@Field(key: "type")
var type: String
init() {}
init(
id: UUID? = nil,
trunkID: TrunkModel.IDValue,
roomID: RoomModel.IDValue,
registers: [Int],
type: TrunkSize.TrunkType
) {
self.id = id
$trunk.id = trunkID
$room.id = roomID
self.registers = registers
self.type = type.rawValue
}
func toDTO() throws -> TrunkSize.RoomProxy {
// guard let room = try await RoomModel.find($room.id, on: database) else {
// throw NotFoundError()
// }
return .init(
room: try room.toDTO(),
registers: registers
)
}
}
final class TrunkModel: Model, @unchecked Sendable {
static let schema = "trunk"
@ID(key: .id)
var id: UUID?
@Parent(key: "projectID")
var project: ProjectModel
@OptionalField(key: "height")
var height: Int?
@Field(key: "type")
var type: String
@OptionalField(key: "name")
var name: String?
@Children(for: \.$trunk)
var rooms: [TrunkRoomModel]
init() {}
init(
id: UUID? = nil,
projectID: Project.ID,
type: TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil
) {
self.id = id
$project.id = projectID
self.height = height
self.type = type.rawValue
self.name = name
}
func toDTO() throws -> TrunkSize {
// let rooms = try await withThrowingTaskGroup(of: TrunkSize.RoomProxy.self) { group in
// for room in self.rooms {
// group.addTask {
// try await room.toDTO(on: database)
// }
// }
//
// return try await group.reduce(into: [TrunkSize.RoomProxy]()) {
// $0.append($1)
// }
//
// }
let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
$0.append(try $1.toDTO())
}
return try .init(
id: requireID(),
projectID: $project.id,
type: .init(rawValue: type)!,
rooms: rooms,
height: height,
name: name
)
}
func applyUpdates(
_ updates: TrunkSize.Update,
on database: any Database
) async throws {
if let type = updates.type, type.rawValue != self.type {
self.type = type.rawValue
}
if let height = updates.height, height != self.height {
self.height = height
}
if let name = updates.name, name != self.name {
self.name = name
}
if hasChanges {
try await self.save(on: database)
}
guard let updateRooms = updates.rooms else {
return
}
// Update rooms.
let rooms = try await TrunkRoomModel.query(on: database)
.with(\.$room)
.filter(\.$trunk.$id == requireID())
.all()
for (roomID, registers) in updateRooms {
if let currRoom = rooms.first(where: { $0.$room.id == roomID }) {
database.logger.debug("CURRENT ROOM: \(currRoom.room.name)")
if registers != currRoom.registers {
database.logger.debug("Updating registers for: \(currRoom.room.name)")
currRoom.registers = registers
}
if currRoom.hasChanges {
try await currRoom.save(on: database)
}
} else {
database.logger.debug("CREATING NEW TrunkRoomModel")
let newModel = try TrunkRoomModel(
trunkID: requireID(),
roomID: roomID,
registers: registers,
type: .init(rawValue: type)!
)
try await newModel.save(on: database)
}
}
let roomsToDelete = rooms.filter {
!updateRooms.keys.contains($0.$room.id)
}
for room in roomsToDelete {
try await room.delete(on: database)
}
database.logger.debug("DONE WITH UPDATES")
}
}
extension Array where Element == TrunkModel {
func toDTO() throws -> [TrunkSize] {
// try await withThrowingTaskGroup(of: TrunkSize.self) { group in
// for model in self {
// group.addTask {
// try await model.toDTO(on: database)
// }
// }
return try reduce(into: [TrunkSize]()) {
$0.append(try $1.toDTO())
}
}
// }
}

View File

@@ -0,0 +1,272 @@
import Dependencies
import DependenciesMacros
import Fluent
import ManualDCore
import Vapor
extension DatabaseClient.UserProfiles: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { profile in
try profile.validate()
let model = profile.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { userID in
try await UserProfileModel.query(on: database)
.with(\.$user)
.filter(\.$user.$id == userID)
.first()
.map { try $0.toDTO() }
},
get: { id in
try await UserProfileModel.find(id, on: database)
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
}
extension User.Profile.Create {
func validate() throws(ValidationError) {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
func toModel() -> UserProfileModel {
.init(
userID: userID,
firstName: firstName,
lastName: lastName,
companyName: companyName,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
theme: theme
)
}
}
extension User.Profile.Update {
func validate() throws(ValidationError) {
if let firstName {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
}
if let lastName {
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
}
if let companyName {
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
}
}
extension User.Profile {
struct Migrate: AsyncMigration {
let name = "Create UserProfile"
func prepare(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema)
.id()
.field("firstName", .string, .required)
.field("lastName", .string, .required)
.field("companyName", .string, .required)
.field("streetAddress", .string, .required)
.field("city", .string, .required)
.field("state", .string, .required)
.field("zipCode", .string, .required)
.field("theme", .string)
.field("userID", .uuid, .references(UserModel.schema, "id", onDelete: .cascade))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "userID")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema).delete()
}
}
}
final class UserProfileModel: Model, @unchecked Sendable {
static let schema = "user_profile"
@ID(key: .id)
var id: UUID?
@Parent(key: "userID")
var user: UserModel
@Field(key: "firstName")
var firstName: String
@Field(key: "lastName")
var lastName: String
@Field(key: "companyName")
var companyName: String
@Field(key: "streetAddress")
var streetAddress: String
@Field(key: "city")
var city: String
@Field(key: "state")
var state: String
@Field(key: "zipCode")
var zipCode: String
@Field(key: "theme")
var theme: String?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
init() {}
init(
id: UUID? = nil,
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil
) {
self.id = id
$user.id = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme?.rawValue
}
func toDTO() throws -> User.Profile {
try .init(
id: requireID(),
userID: $user.id,
firstName: firstName,
lastName: lastName,
companyName: companyName,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
theme: self.theme.flatMap(Theme.init),
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: User.Profile.Update) {
if let firstName = updates.firstName, firstName != self.firstName {
self.firstName = firstName
}
if let lastName = updates.lastName, lastName != self.lastName {
self.lastName = lastName
}
if let companyName = updates.companyName, companyName != self.companyName {
self.companyName = companyName
}
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
self.streetAddress = streetAddress
}
if let city = updates.city, city != self.city {
self.city = city
}
if let state = updates.state, state != self.state {
self.state = state
}
if let zipCode = updates.zipCode, zipCode != self.zipCode {
self.zipCode = zipCode
}
if let theme = updates.theme, theme.rawValue != self.theme {
self.theme = theme.rawValue
}
}
}

View File

@@ -0,0 +1,273 @@
import Dependencies
import DependenciesMacros
import Fluent
import ManualDCore
import Vapor
extension DatabaseClient.Users: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await UserModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
get: { id in
try await UserModel.find(id, on: database).map { try $0.toDTO() }
},
login: { request in
guard
let user = try await UserModel.query(on: database)
.with(\.$token)
.filter(\UserModel.$email == request.email)
.first()
else {
throw NotFoundError()
}
let token: User.Token
// Check if there's a user token
if let userToken = user.token {
token = try userToken.toDTO()
} else {
// generate a new token
let tokenModel = try user.generateToken()
try await tokenModel.save(on: database)
token = try tokenModel.toDTO()
}
return token
},
logout: { tokenID in
guard let token = try await UserTokenModel.find(tokenID, on: database) else { return }
try await token.delete(on: database)
}
// ,
// token: { id in
// }
)
}
}
extension User {
struct Migrate: AsyncMigration {
let name = "CreateUser"
func prepare(on database: any Database) async throws {
try await database.schema(UserModel.schema)
.id()
.field("email", .string, .required)
.field("password_hash", .string, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "email")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserModel.schema).delete()
}
}
}
extension User.Token {
struct Migrate: AsyncMigration {
let name = "CreateUserToken"
func prepare(on database: any Database) async throws {
try await database.schema(UserTokenModel.schema)
.id()
.field("value", .string, .required)
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "value")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserTokenModel.schema).delete()
}
}
}
extension User {
static func hashPassword(_ password: String) throws -> String {
try Bcrypt.hash(password, cost: 12)
}
}
extension User.Create {
func toModel() throws -> UserModel {
try validate()
return try .init(email: email, passwordHash: User.hashPassword(password))
}
func validate() throws {
guard !email.isEmpty else {
throw ValidationError("Email should not be empty")
}
guard password.count > 8 else {
throw ValidationError("Password should be more than 8 characters long.")
}
guard password == confirmPassword else {
throw ValidationError("Passwords do not match.")
}
}
}
final class UserModel: Model, @unchecked Sendable {
static let schema = "user"
@ID(key: .id)
var id: UUID?
@Field(key: "email")
var email: String
@Field(key: "password_hash")
var passwordHash: String
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@OptionalChild(for: \.$user)
var token: UserTokenModel?
init() {}
init(
id: UUID? = nil,
email: String,
passwordHash: String
) {
self.id = id
self.email = email
self.passwordHash = passwordHash
}
func toDTO() throws -> User {
try .init(
id: requireID(),
email: email,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func generateToken() throws -> UserTokenModel {
try .init(
value: [UInt8].random(count: 16).base64,
userID: requireID()
)
}
func verifyPassword(_ password: String) throws -> Bool {
try Bcrypt.verify(password, created: passwordHash)
}
}
final class UserTokenModel: Model, Codable, @unchecked Sendable {
static let schema = "user_token"
@ID(key: .id)
var id: UUID?
@Field(key: "value")
var value: String
@Parent(key: "user_id")
var user: UserModel
init() {}
init(id: UUID? = nil, value: String, userID: UserModel.IDValue) {
self.id = id
self.value = value
$user.id = userID
}
func toDTO() throws -> User.Token {
try .init(id: requireID(), userID: $user.id, value: value)
}
}
// MARK: - Authentication
extension User: Authenticatable {}
extension User: SessionAuthenticatable {
public var sessionID: String { email }
}
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
public typealias User = ManualDCore.User
public init() {}
public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
guard
let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$email == basic.username)
.first(),
try user.verifyPassword(basic.password)
else {
throw Abort(.unauthorized)
}
try request.auth.login(user.toDTO())
}
}
public struct UserTokenAuthenticator: AsyncBearerAuthenticator {
public typealias User = ManualDCore.User
public init() {}
public func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
guard
let token = try await UserTokenModel.query(on: request.db)
.filter(\UserTokenModel.$value == bearer.token)
.with(\UserTokenModel.$user)
.first()
else {
throw Abort(.unauthorized)
}
try request.auth.login(token.user.toDTO())
}
}
public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
public typealias User = ManualDCore.User
public init() {}
public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
guard
let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$email == sessionID)
.first()
else {
throw Abort(.unauthorized)
}
try request.auth.login(user.toDTO())
}
}