From b7d246106cd6e3488b0fd8427f2182a4f89ee60d Mon Sep 17 00:00:00 2001 From: alephao <7674479+alephao@users.noreply.github.com> Date: Thu, 8 May 2025 15:43:00 -0300 Subject: [PATCH] feat: databaseInitialized --- Sources/StructuredQueries/Macros.swift | 4 +- .../Internal/PatternBindingSyntax.swift | 9 + .../StructuredQueriesMacros/TableMacro.swift | 129 +++++- .../TableMacroTests.swift | 422 ++++++++++++++++++ 4 files changed, 559 insertions(+), 5 deletions(-) diff --git a/Sources/StructuredQueries/Macros.swift b/Sources/StructuredQueries/Macros.swift index c54c1c20..08ea539b 100644 --- a/Sources/StructuredQueries/Macros.swift +++ b/Sources/StructuredQueries/Macros.swift @@ -34,11 +34,13 @@ public macro Table(_ name: String? = nil) = /// - representableType: A type that represents the property type in a query expression. For types /// that don't have a single representation in SQL, like `Date` and `UUID`. /// - primaryKey: The column is its table's auto-incrementing primary key. +/// - databaseInitialized: The column has a default value and is not needed in an insert statement. @attached(accessor, names: named(willSet)) public macro Column( _ name: String? = nil, as representableType: (any QueryRepresentable.Type)? = nil, - primaryKey: Bool = false + primaryKey: Bool? = nil, + databaseInitialized: Bool? = nil ) = #externalMacro( module: "StructuredQueriesMacros", diff --git a/Sources/StructuredQueriesMacros/Internal/PatternBindingSyntax.swift b/Sources/StructuredQueriesMacros/Internal/PatternBindingSyntax.swift index 15b4165f..edfa4d56 100644 --- a/Sources/StructuredQueriesMacros/Internal/PatternBindingSyntax.swift +++ b/Sources/StructuredQueriesMacros/Internal/PatternBindingSyntax.swift @@ -37,4 +37,13 @@ extension PatternBindingSyntax { optionalized.typeAnnotation?.type = optionalType return optionalized } + + func isOptional() -> Bool { + // x: Optional or x: T? + if self.typeAnnotation?.type.isOptionalType == true { return true } + // Missing cases + // x = Optional.some(_) + // x = fnReturningOptionalType() + return false + } } diff --git a/Sources/StructuredQueriesMacros/TableMacro.swift b/Sources/StructuredQueriesMacros/TableMacro.swift index 010d942d..30ca145a 100644 --- a/Sources/StructuredQueriesMacros/TableMacro.swift +++ b/Sources/StructuredQueriesMacros/TableMacro.swift @@ -96,6 +96,7 @@ extension TableMacro: ExtensionMacro { var columnQueryOutputType = columnQueryValueType var isPrimaryKey = primaryKey == nil && identifier.text == "id" var isEphemeral = false + var databaseInitialized: Bool? = nil for attribute in property.attributes { guard @@ -182,6 +183,89 @@ extension TableMacro: ExtensionMacro { queryValueType: columnQueryValueType ) + case let .some(label) where label.text == "databaseInitialized": + guard + let tokenKind = argument.expression.as(BooleanLiteralExprSyntax.self)?.literal + .tokenKind + else { + diagnostics.append( + Diagnostic( + node: argument.expression, + message: MacroExpansionErrorMessage( + "Argument 'databaseInitialized' must be a boolean literal") + ) + ) + break + } + + databaseInitialized = tokenKind == .keyword(.true) + + if databaseInitialized! && binding.isOptional() { + diagnostics.append( + Diagnostic( + node: argument.expression, + message: MacroExpansionErrorMessage( + "Can't use `databaseInitialized: true` on an optional property") + ) + ) + break + } + + let hasPrimaryKeySemantics = + (isPrimaryKey || primaryKey?.identifier.text == identifier.text) + + switch (hasPrimaryKeySemantics, databaseInitialized!) { + case (true, true): + var newArguments = arguments + newArguments.remove(at: argumentIndex) + if var lastArgument = newArguments.last { + lastArgument.trailingComma = nil + newArguments[newArguments.index(before: newArguments.endIndex)] = lastArgument + } + diagnostics.append( + Diagnostic( + node: argument.expression, + message: MacroExpansionWarningMessage( + isPrimaryKey + ? "'databaseInitialized: true' is redundant for primary keys" + : "'databaseInitialized: true' is redundant with 'primaryKey: true'" + ), + fixIt: .replace( + message: MacroExpansionFixItMessage("Remove 'databaseInitialized: true'"), + oldNode: Syntax(attribute), + newNode: Syntax(attribute.with(\.arguments, .argumentList(newArguments))) + ) + ) + ) + break + + case (false, false): + var newArguments = arguments + newArguments.remove(at: argumentIndex) + if var lastArgument = newArguments.last { + lastArgument.trailingComma = nil + newArguments[newArguments.index(before: newArguments.endIndex)] = lastArgument + } + diagnostics.append( + Diagnostic( + node: argument.expression, + message: MacroExpansionWarningMessage( + isPrimaryKey + ? "'databaseInitialized: false' is redundant with 'primaryKey: false'" + : "'databaseInitialized: false' is redundant for non primary keys" + ), + fixIt: .replace( + message: MacroExpansionFixItMessage("Remove 'databaseInitialized: false'"), + oldNode: Syntax(attribute), + newNode: Syntax(attribute.with(\.arguments, .argumentList(newArguments))) + ) + ) + ) + break + + default: break + } + case let argument?: fatalError("Unexpected argument: \(argument)") } @@ -200,7 +284,7 @@ extension TableMacro: ExtensionMacro { } // NB: A compiled bug prevents us from applying the '@_Draft' macro directly - if identifier == primaryKey?.identifier { + if databaseInitialized == true || identifier == primaryKey?.identifier { draftBindings.append((binding.optionalized(), columnQueryOutputType)) } else { draftBindings.append((binding, columnQueryOutputType)) @@ -336,6 +420,7 @@ extension TableMacro: ExtensionMacro { else { continue } hasColumnAttribute = true var hasPrimaryKeyArgument = false + var databaseInitializedIndex: DefaultIndices.Element? = nil for argumentIndex in arguments.indices { var argument = arguments[argumentIndex] defer { arguments[argumentIndex] = argument } @@ -350,11 +435,17 @@ extension TableMacro: ExtensionMacro { hasPrimaryKeyArgument = true argument.expression = ExprSyntax(BooleanLiteralExprSyntax(false)) + case "databaseInitialized": + databaseInitializedIndex = argumentIndex + default: break } } - if !hasPrimaryKeyArgument { + if let databaseInitializedIndex { + arguments.remove(at: databaseInitializedIndex) + } + if !hasPrimaryKeyArgument, arguments.count > 0 { arguments[arguments.index(before: arguments.endIndex)].trailingComma = .commaToken( trailingTrivia: .space ) @@ -389,6 +480,30 @@ extension TableMacro: ExtensionMacro { ) ) } else { + var property = property + var binding = binding + + if databaseInitialized == true, let type = binding.typeAnnotation?.type.asOptionalType() { + if let columnAttributeIdx = property.attributes.firstIndex(where: { + $0.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text + == "Column" + }), + var columnAttribute = property.attributes[columnAttributeIdx].as(AttributeSyntax.self), + case var .argumentList(arguments) = columnAttribute.arguments + { + if arguments.count <= 1 { + property.attributes.remove(at: columnAttributeIdx) + } else if let dabaseInitializeIdx = arguments.firstIndex(where: { + $0.label?.text == "databaseInitialized" + }) { + arguments.remove(at: dabaseInitializeIdx) + columnAttribute.arguments = .argumentList(arguments) + property.attributes[columnAttributeIdx] = .attribute(columnAttribute) + } + } + binding.typeAnnotation?.type = type + } + property.bindings = [binding] draftProperties.append( DeclSyntax( property.trimmed @@ -539,8 +654,14 @@ extension TableMacro: ExtensionMacro { ) } - guard diagnostics.isEmpty else { - diagnostics.forEach(context.diagnose) + var hasErrorDiagnostic = false + for diagnostic in diagnostics { + context.diagnose(diagnostic) + if diagnostic.diagMessage.severity == .error { + hasErrorDiagnostic = true + } + } + guard !hasErrorDiagnostic else { return [] } diff --git a/Tests/StructuredQueriesMacrosTests/TableMacroTests.swift b/Tests/StructuredQueriesMacrosTests/TableMacroTests.swift index 146fad0d..82c53b24 100644 --- a/Tests/StructuredQueriesMacrosTests/TableMacroTests.swift +++ b/Tests/StructuredQueriesMacrosTests/TableMacroTests.swift @@ -1627,4 +1627,426 @@ extension SnapshotTests { } } } + + @MainActor + @Suite struct DatabaseInitializedTests { + @Test func basic() { + assertMacro { + """ + @Table + struct Foo { + var id: Int + var foo: String + @Column(databaseInitialized: true) + var created: Int + } + """ + } expansion: { + #""" + struct Foo { + var id: Int + var foo: String + var created: Int + } + + extension Foo: StructuredQueries.Table, StructuredQueries.PrimaryKeyedTable { + public struct TableColumns: StructuredQueries.TableDefinition, StructuredQueries.PrimaryKeyedTableDefinition { + public typealias QueryValue = Foo + public let id = StructuredQueries.TableColumn("id", keyPath: \QueryValue.id) + public let foo = StructuredQueries.TableColumn("foo", keyPath: \QueryValue.foo) + public let created = StructuredQueries.TableColumn("created", keyPath: \QueryValue.created) + public var primaryKey: StructuredQueries.TableColumn { + self.id + } + public static var allColumns: [any StructuredQueries.TableColumnExpression] { + [QueryValue.columns.id, QueryValue.columns.foo, QueryValue.columns.created] + } + } + public struct Draft: StructuredQueries.TableDraft { + public typealias PrimaryTable = Foo + @Column(primaryKey: false) + var id: Int? + var foo: String + var created: Int? + public struct TableColumns: StructuredQueries.TableDefinition { + public typealias QueryValue = Foo.Draft + public let id = StructuredQueries.TableColumn("id", keyPath: \QueryValue.id) + public let foo = StructuredQueries.TableColumn("foo", keyPath: \QueryValue.foo) + public let created = StructuredQueries.TableColumn("created", keyPath: \QueryValue.created) + public static var allColumns: [any StructuredQueries.TableColumnExpression] { + [QueryValue.columns.id, QueryValue.columns.foo, QueryValue.columns.created] + } + } + public static let columns = TableColumns() + public static let tableName = Foo.tableName + public init(decoder: inout some StructuredQueries.QueryDecoder) throws { + self.id = try decoder.decode(Int.self) + let foo = try decoder.decode(String.self) + self.created = try decoder.decode(Int.self) + guard let foo else { + throw QueryDecodingError.missingRequiredColumn + } + self.foo = foo + } + public init(_ other: Foo) { + self.id = other.id + self.foo = other.foo + self.created = other.created + } + public init( + id: Int? = nil, + foo: String, + created: Int? = nil + ) { + self.id = id + self.foo = foo + self.created = created + } + } + public static let columns = TableColumns() + public static let tableName = "foos" + public init(decoder: inout some StructuredQueries.QueryDecoder) throws { + let id = try decoder.decode(Int.self) + let foo = try decoder.decode(String.self) + let created = try decoder.decode(Int.self) + guard let id else { + throw QueryDecodingError.missingRequiredColumn + } + guard let foo else { + throw QueryDecodingError.missingRequiredColumn + } + guard let created else { + throw QueryDecodingError.missingRequiredColumn + } + self.id = id + self.foo = foo + self.created = created + } + } + """# + } + } + + @Test func nilArgument() { + assertMacro { + """ + @Table + struct Foo { + @Column(databaseInitialized: nil) + var bar: Int + } + """ + } diagnostics: { + """ + @Table + struct Foo { + @Column(databaseInitialized: nil) + ┬── + ╰─ 🛑 Argument 'databaseInitialized' must be a boolean literal + var bar: Int + } + """ + } + } + + @Test func optionalProperty() { + assertMacro { + """ + @Table + struct Foo { + @Column(databaseInitialized: true) + var bar: Int? + } + """ + } diagnostics: { + """ + @Table + struct Foo { + @Column(databaseInitialized: true) + ┬─── + ╰─ 🛑 Can't use `databaseInitialized: true` on an optional property + var bar: Int? + } + """ + } + } + + @Test func idProperty() { + assertMacro { + """ + @Table + struct Foo { + @Column(databaseInitialized: true) + var id: Int + } + """ + } diagnostics: { + """ + @Table + struct Foo { + @Column(databaseInitialized: true) + ┬─── + ╰─ ⚠️ 'databaseInitialized: true' is redundant for primary keys + ✏️ Remove 'databaseInitialized: true' + var id: Int + } + """ + } fixes: { + """ + @Table + struct Foo { + @Column() + var id: Int + } + """ + } expansion: { + #""" + struct Foo { + var id: Int + } + + extension Foo: StructuredQueries.Table, StructuredQueries.PrimaryKeyedTable { + public struct TableColumns: StructuredQueries.TableDefinition, StructuredQueries.PrimaryKeyedTableDefinition { + public typealias QueryValue = Foo + public let id = StructuredQueries.TableColumn("id", keyPath: \QueryValue.id) + public var primaryKey: StructuredQueries.TableColumn { + self.id + } + public static var allColumns: [any StructuredQueries.TableColumnExpression] { + [QueryValue.columns.id] + } + } + public struct Draft: StructuredQueries.TableDraft { + public typealias PrimaryTable = Foo + @Column() var id: Int? + public struct TableColumns: StructuredQueries.TableDefinition, StructuredQueries.PrimaryKeyedTableDefinition { + public typealias QueryValue = Foo.Draft + public let id = StructuredQueries.TableColumn("id", keyPath: \QueryValue.id) + public static var allColumns: [any StructuredQueries.TableColumnExpression] { + [QueryValue.columns.id] + } + } + public static let columns = TableColumns() + public static let tableName = Foo.tableName + public init(decoder: inout some StructuredQueries.QueryDecoder) throws { + self.id = try decoder.decode(Int.self) + } + public init(_ other: Foo) { + self.id = other.id + } + public init( + id: Int? = nil + ) { + self.id = id + } + } + public static let columns = TableColumns() + public static let tableName = "foos" + public init(decoder: inout some StructuredQueries.QueryDecoder) throws { + let id = try decoder.decode(Int.self) + guard let id else { + throw QueryDecodingError.missingRequiredColumn + } + self.id = id + } + } + """# + } + } + + @Test func primaryKey() { + assertMacro { + """ + @Table + struct Foo { + @Column(primaryKey: true, databaseInitialized: true) + var bar: Int + } + """ + } diagnostics: { + """ + @Table + struct Foo { + @Column(primaryKey: true, databaseInitialized: true) + ┬─── + ╰─ ⚠️ 'databaseInitialized: true' is redundant with 'primaryKey: true' + ✏️ Remove 'databaseInitialized: true' + var bar: Int + } + """ + } fixes: { + """ + @Table + struct Foo { + @Column(primaryKey: true) + var bar: Int + } + """ + } expansion: { + #""" + struct Foo { + var bar: Int + } + + extension Foo: StructuredQueries.Table, StructuredQueries.PrimaryKeyedTable { + public struct TableColumns: StructuredQueries.TableDefinition, StructuredQueries.PrimaryKeyedTableDefinition { + public typealias QueryValue = Foo + public let bar = StructuredQueries.TableColumn("bar", keyPath: \QueryValue.bar) + public var primaryKey: StructuredQueries.TableColumn { + self.bar + } + public static var allColumns: [any StructuredQueries.TableColumnExpression] { + [QueryValue.columns.bar] + } + } + public struct Draft: StructuredQueries.TableDraft { + public typealias PrimaryTable = Foo + @Column(primaryKey: false) var bar: Int? + public struct TableColumns: StructuredQueries.TableDefinition { + public typealias QueryValue = Foo.Draft + public let bar = StructuredQueries.TableColumn("bar", keyPath: \QueryValue.bar) + public static var allColumns: [any StructuredQueries.TableColumnExpression] { + [QueryValue.columns.bar] + } + } + public static let columns = TableColumns() + public static let tableName = Foo.tableName + public init(decoder: inout some StructuredQueries.QueryDecoder) throws { + self.bar = try decoder.decode(Int.self) + } + public init(_ other: Foo) { + self.bar = other.bar + } + public init( + bar: Int? = nil + ) { + self.bar = bar + } + } + public static let columns = TableColumns() + public static let tableName = "foos" + public init(decoder: inout some StructuredQueries.QueryDecoder) throws { + let bar = try decoder.decode(Int.self) + guard let bar else { + throw QueryDecodingError.missingRequiredColumn + } + self.bar = bar + } + } + """# + } + } + + @Test func idNonPrimaryKeyAndFalse() { + assertMacro { + """ + @Table + struct Foo { + @Column(primaryKey: false, databaseInitialized: false) + var id: Int + } + """ + } diagnostics: { + """ + @Table + struct Foo { + @Column(primaryKey: false, databaseInitialized: false) + ┬──── + ╰─ ⚠️ 'databaseInitialized: false' is redundant for non primary keys + ✏️ Remove 'databaseInitialized: false' + var id: Int + } + """ + } fixes: { + """ + @Table + struct Foo { + @Column(primaryKey: false) + var id: Int + } + """ + } expansion: { + #""" + struct Foo { + var id: Int + } + + extension Foo: StructuredQueries.Table { + public struct TableColumns: StructuredQueries.TableDefinition { + public typealias QueryValue = Foo + public let id = StructuredQueries.TableColumn("id", keyPath: \QueryValue.id) + public static var allColumns: [any StructuredQueries.TableColumnExpression] { + [QueryValue.columns.id] + } + } + public static let columns = TableColumns() + public static let tableName = "foos" + public init(decoder: inout some StructuredQueries.QueryDecoder) throws { + let id = try decoder.decode(Int.self) + guard let id else { + throw QueryDecodingError.missingRequiredColumn + } + self.id = id + } + } + """# + } + } + + @Test func regularPropertyFalse() { + assertMacro { + """ + @Table + struct Foo { + @Column(databaseInitialized: false) + var bar: String + } + """ + } diagnostics: { + """ + @Table + struct Foo { + @Column(databaseInitialized: false) + ┬──── + ╰─ ⚠️ 'databaseInitialized: false' is redundant for non primary keys + ✏️ Remove 'databaseInitialized: false' + var bar: String + } + """ + } fixes: { + """ + @Table + struct Foo { + @Column() + var bar: String + } + """ + } expansion: { + #""" + struct Foo { + var bar: String + } + + extension Foo: StructuredQueries.Table { + public struct TableColumns: StructuredQueries.TableDefinition { + public typealias QueryValue = Foo + public let bar = StructuredQueries.TableColumn("bar", keyPath: \QueryValue.bar) + public static var allColumns: [any StructuredQueries.TableColumnExpression] { + [QueryValue.columns.bar] + } + } + public static let columns = TableColumns() + public static let tableName = "foos" + public init(decoder: inout some StructuredQueries.QueryDecoder) throws { + let bar = try decoder.decode(String.self) + guard let bar else { + throw QueryDecodingError.missingRequiredColumn + } + self.bar = bar + } + } + """# + } + } + } }