Skip to content

Conversation

@remimarsal
Copy link
Contributor

This PR introduces support for database-generated columns, as discussed in the proposal and subsequent feedback.

Motivation

Databases often use generated columns (e.g., GENERATED ALWAYS AS ... in SQLite) to compute values automatically. Currently, swift-structured-queries lacks a way to model these read-only columns, as they must be included in SELECT queries but excluded from INSERT and UPDATE operations. Marking them as @Ephemeral is incorrect because the column does exist in the database.

Implementation

@Column(generated: .stored)
let computedColumn: String
  • The generated parameter accepts .stored or .virtual.
  • The Table macro now identifies properties with this attribute and includes them in the TableColumns definition for SELECT queries.
  • Generated properties are excluded from the Draft struct, preventing them from being part of INSERT or UPDATE statements.

Copy link
Member

@stephencelis stephencelis left a comment

Choose a reason for hiding this comment

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

This is looking really great! Just a couple comments. Let us know what you think of the suggestions and if you have any of your own.

Comment on lines 212 to 226
guard
let memberName = argument.expression.as(MemberAccessExprSyntax.self)?.declName
.baseName.text,
["stored", "virtual"].contains(memberName)
else {
diagnostics.append(
Diagnostic(
node: argument.expression,
message: MacroExpansionErrorMessage(
"Argument 'generated' must be '.stored' or '.virtual'"
)
)
)
continue
}
Copy link
Member

Choose a reason for hiding this comment

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

I think the type-checking done at the macro level is sufficient enough, so these diagnostics shouldn't be necessary.

Suggested change
guard
let memberName = argument.expression.as(MemberAccessExprSyntax.self)?.declName
.baseName.text,
["stored", "virtual"].contains(memberName)
else {
diagnostics.append(
Diagnostic(
node: argument.expression,
message: MacroExpansionErrorMessage(
"Argument 'generated' must be '.stored' or '.virtual'"
)
)
)
continue
}

Comment on lines 485 to 488
public let generated = StructuredQueriesCore.TableColumn<QueryValue, String>("generated", keyPath: \QueryValue.generated)
public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] {
[QueryValue.columns.name, QueryValue.columns.generated]
}
Copy link
Member

@stephencelis stephencelis Jun 27, 2025

Choose a reason for hiding this comment

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

I haven't tried it yet, but I think exposing generated table columns as TableColumn means they could still be used to generate invalid INSERT and UPDATE statements that attempt to assign values to these columns in non-draft versions of the builders. Ideally we should make generated columns distinguishable in some way to prevent that.

The simplest solution could look something like:

-public let generated = StructuredQueriesCore.TableColumn<QueryValue, String>("generated", keyPath: \QueryValue.generated)
+public var generated: some QueryExpression<String> {
+  StructuredQueriesCore.TableColumn<QueryValue, String>("generated", keyPath: \QueryValue.generated)
+  // Or even 'SQLQueryExpression("\(QueryValue.self).\(quote: "generated")")'
+}
 public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] {
-  [QueryValue.columns.name, QueryValue.columns.generated]
+  [QueryValue.columns.name]
 }

Now this change alone isn't enough to work because TableDefinition relies on allColumns to build the selected columns in the query, but maybe we start generating the queryFragment directly in the macro now instead of relying on that extension.

With those changes my hope is that SELECT queries and decoding should still work fine, and INSERT and UPDATE statements won't be able to "assign" to generated column.

A more complex solution might be to have a kind of "hierarchy" of WritableTableColumnExpression to distinguish things at the type level, but that's a bigger design challenge and I think could come later if we can motivate it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So I tried experimenting with generating the query fragment directly in the macro but I'm not sure how it should be managed after generation.

  • Does generating TableColumns like this make sense?
public let name = StructuredQueriesCore.TableColumn<QueryValue, String>("name", keyPath: \QueryValue.name)
public var generated: some StructuredQueriesCore.QueryExpression<String> {
  StructuredQueriesCore.TableColumn<QueryValue, String>("generated", keyPath: \QueryValue.generated)
}
public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] {
  [QueryValue.columns.name]
}
public static var queryFragment: String {
  #""name", "generated""#
}
  • The newly generated queryFragment does not trigger any error or changes on generated SQL queries. Where should we connect queryFragment so that it gets used?

Copy link
Member

@stephencelis stephencelis Jul 8, 2025

Choose a reason for hiding this comment

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

@remimarsal I think it can be non-static and return a QueryFragment (which can be a string literal). Something like:

-public static var queryFragment: String {
+public var queryFragment: QueryFragment {
   #""name", "generated""#
 }

Does that work?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes thank you, it does work. However, simply generating the column names as String is not enough to perform queries with joins as we also need the table names. Since there is a somewhat complex logic regarding tableName in ExtensionMacro, and we are generating the query fragment in the MemberMacro, we may want to generate a computed property instead like below to avoid duplication. What do you think?

public var queryFragment: QueryFragment {
  QueryFragment(stringLiteral: [\(raw: selectableColumns)].map { "\\"\\(QueryValue.tableName)\\".\\"\\($0)\\"" }
  .joined(separator: ", "))
}

Copy link
Member

Choose a reason for hiding this comment

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

Yup we definitely want to prefix everything with the quoted table name, as well. Wanna push code that gets things working and we can consider any cleanup after?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great, I just pushed

@stephencelis
Copy link
Member

@remimarsal Just wanted to know if you were up for those changes. If not we can try to take this PR to the finish line for you. I just didn't want to step on your toes if you were still in the middle of it :)

@remimarsal
Copy link
Contributor Author

@stephencelis Absolutely, I'll look into it today! ;)

Copy link
Member

@stephencelis stephencelis left a comment

Choose a reason for hiding this comment

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

Things are looking great! Pushed a small bit of cleanup around the query fragment and I think we'll be ready to merge on green.

@stephencelis stephencelis merged commit 6f9d9f5 into pointfreeco:main Jul 8, 2025
3 checks passed
@remimarsal
Copy link
Contributor Author

remimarsal commented Jul 11, 2025

Hi @stephencelis, following up on this feature.

While our previous change successfully prevents runtime SQL errors when a generated column is manually added to an INSERT statement, .e.g.

try Event.insert {
    ($0.id, $0.startAt, $0.duration, $0.endAt) // type error because `endAt` is database generated
} values: {
    (id(), now(), 60, now())
}

I think we are still missing a different use case. Given the following model:

@Table
struct Event {
    var id: UUID
    var startAt: Date
    var duration: TimeInterval

    @Column(generated: .stored)
    var endAt: Date
}

When inserting a model instance and using .returning(\.self), we have a runtime error. The decoder expects the generated column endAt, but since it's no longer in the list of columns returned by the INSERT query, a decoding error occurs. For example:

let event = Event(
    id: id(),
    startAt: now(),
    duration: 5 * 60
)

let query = Event.insert { event }.returning(\.self)

// This fails because `endAt` is not in the
// returned data, causing a decoding mismatch.
return try query.fetchOne(db)!

coenttb pushed a commit to coenttb/swift-structured-queries-postgres that referenced this pull request Oct 14, 2025
coenttb pushed a commit to coenttb/swift-structured-queries-postgres that referenced this pull request Oct 15, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants