Hoje estamos lançando a versão 1.8.0 do SQLiteData, nossa alternativa ao SwiftData construída diretamente sobre SQLite. Ela vem com uma nova ferramenta para agrupar os resultados de uma consulta em seções em apenas uma linha de código.
Suponha que você tenha uma tabela de lembretes com uma categoria opcional:
@Table struct Reminder {
let id: UUID
var title = ""
var category: String?
var dueDate: Date?
var remindersListID: RemindersList.ID
}
Se você deseja exibir todos os lembretes em uma lista, agrupados por categoria, pode fornecer um argumento sectionBy: ao @FetchAll:
struct RemindersView: View {
@FetchAll(Reminder.order(by: \Reminder.title), sectionBy: \Reminder.category)
var reminders
var body: some View {
…
}
}
No corpo da visualização, o valor projetado $reminders tem uma propriedade sections que pode ser iterada para exibir cada seção:
var body: some View {
List {
ForEach($reminders.sections) { section in
Section(section.name ?? "Uncategorized") {
ForEach(section) { reminder in
Text(reminder.title)
}
}
}
}
}
Cada section pode ser iterado e tem uma propriedade name que descreve a seção.
É tudo o que é necessário, e parece bastante semelhante ao argumento sectionBy: que o macro @Query do SwiftData ganhou no appleOS 27+. Mas aqui terminam as similaridades. A agrupação do SwiftData está restrita aos sistemas operacionais modernos da Apple e às propriedades de string nos modelos, enquanto a agrupação do SQLiteData aceita qualquer expressão SQL, o que desbloqueia muito mais.
O argumento sectionBy: não é apenas uma chave de caminho. É um fechamento que recebe o esquema da tabela sendo consultada, e assim você pode agrupar por qualquer expressão de string que possa imaginar. Por exemplo, se quiser agrupar lembretes alfabeticamente pelo primeiro caractere do título, como no protótipo usado pelo aplicativo Contatos, pode invocar a função substr do SQLite diretamente:
@FetchAll(
Reminder.order(by: \title),
sectionBy: { $0.title.substr(1, 1) }
)
var reminders
Ou você pode agrupar por dados que não estão armazenados em nenhuma coluna, como se um lembrete foi agendado ou não:
@FetchAll(
Reminder.order(by: \title),
sectionBy: {
Case()
.when($0.dueDate.isNot(nil), then: "Scheduled")
.else("Unscheduled")
}
)
var reminders
Você pode até especificar como as seções são ordenadas fornecendo uma cláusula ascendente ou descendente. Portanto, se você quiser agrupar pela primeira letra dos títulos de lembretes em ordem decrescente, basta fazer isso:
@FetchAll(
Reminder.order(by: \title),
sectionBy: { $0.title.substr(1, 1).desc() }
)
var reminders
Ao agrupar por algo que pode ser NULL, você pode controlar onde os valores NULL são colocados. Ou no início, ou no final:
@FetchAll(
Reminder.order(by: \title),
sectionBy: { $0.category.asc(nulls: .last) }
)
var reminders
Note que a seção “Não categorizada” agora está no final em vez do início.
Nada disso é possível com SwiftData: as seções sempre são ordenadas alfabeticamente, de forma ascendente, por uma propriedade de string armazenada.
A seção não está restrita aos dados na tabela sendo consultada. Com o poder completo do SQL à sua disposição, incluindo junções, você pode criar seções por meio de dados armazenados em outras tabelas.
Por exemplo, se você deseja exibir todas as lembretes agrupadas pelo título da lista a que pertencem, pode juntar a tabela RemindersList à consulta:
@FetchAll(
Reminder
.order(by: \Reminder.title)
.join(RemindersList.all) { $0.remindersListID.eq($1.id) }
.select { reminder, _ in reminder },
sectionBy: { _, remindersList in remindersList.title }
)
var reminders
A clausura sectionBy: recebe o esquema de todas as tabelas na junção, e então agrupar por título da lista é tão simples quanto alcançá-lo.
A seção é algo que você geralmente dá aos usuários controle, e assim deve ser dinâmica. O argumento sectionBy: também está disponível no método load do valor projetado de @FetchAll, o que significa que você pode recarregar uma consulta com nova seção em qualquer momento.
Suponha que sua funcionalidade mantenha algum estado descrevendo como o usuário deseja agrupar seus lembretes:
enum GroupOption {
case none
case category
case titleFirstLetter
}
@State var group = GroupOption.none
Então você pode carregar uma nova consulta sempre que esse estado mudar:
.task(id: group) {
try? await $reminders.load(
Reminder.order(by: \Reminder.title),
sectionBy: {
switch group {
case .none: nil
case .category: $0.category
case .titleFirstLetter: $0.title.substr(1, 1)
}
},
animation: .default
)
}
A clausura sectionBy: é um contexto de construção, e assim você está livre para usar declarações if e switch para decidir como os resultados devem ser agrupados, e até mesmo retornar nil para desativar a seção completamente.
Quando a seção é desativada, a coleção sections é populada com uma única seção não nomeada contendo todas as linhas. Isso significa que você pode estruturar sua visualização assim:
var body: some View {
List {
ForEach($reminders.sections) { section in
Section {
ForEach(section) { reminder in
Text(reminder.title)
}
} header: {
if let name = section.name {
Text(name)
}
}
}
}
}
…e funcionará independentemente de seus resultados estarem ou não seccionados. Não há necessidade de ramificar sua hierarquia de visualização para verificar se a seção está vazia e manter duas hierarquias quase idênticas.
O banco de dados faz o trabalho
É importante destacar o que não está acontecendo aqui: em nenhum momento os resultados são carregados na memória e agrupados pelo seu aplicativo. A expressão que você passa para sectionBy: é anexada à cláusula ORDER BY da consulta e avaliada pelo SQLite, e os resultados são agrupados diretamente conforme decodificados a partir da conexão.
SELECT
"reminders"."id", "reminders"."title", …, substr("reminders"."title", 1, 1)
FROM "reminders"
ORDER BY substr("reminders"."title", 1, 1) DESC, "reminders"."title"
Note a expressão de seccionamento anexada à cláusula ORDER BY. Isso significa que você não precisa lembrar de ordenar sua consulta pelo que está sendo seccionado. O SQLiteData cuida disso para você, e a ordem especificada na própria consulta é usada dentro de cada seção.
@FetchAll(sectionBy:) nomeia as seções com strings, assim como o SwiftData faz, e isso cobre a tarefa dinâmica mais comum, mas não é a única ferramenta à sua disposição. Desde sua primeira versão foi possível agrupar os resultados de consultas no SQLiteData usando FetchKeyRequest, que permite executar qualquer número de consultas em uma única transação do banco de dados e transformar os resultados em qualquer estrutura de dados que você deseje.
E esta versão traz seccionamento a essa ferramenta também. Agora cada consulta tem um método fetchAll(_:sectionBy:) que retorna seus resultados agrupados em seções, o que significa que você pode seccionar os resultados em qualquer lugar onde tenha uma conexão com o banco de dados.
Você pode usar essa ferramenta quando precisar de controle mais preciso sobre o tipo que secciona os resultados. Qualquer valor hashável pode nomear uma seção, incluindo seus próprios enums. Suponha que as lembretes também tenham uma prioridade:
@Table struct Reminder {
…
var priority: Priority?
}
enum Priority: Int, QueryBindable { case low, medium, high }
Claro que você poderia transformar isso em uma string para seccionar por ela, mas então estaria descartando tudo o que o tipo lhe deu. Sua visualização teria que fazer um switch sobre "high" e "low" literais de string com um caso padrão que nunca deveria acontecer, e pior ainda, suas seções voltariam na ordem errada: ordenando "high", "low" e "medium" alfabeticamente.
Em vez disso, você pode seccionar pela própria coluna de prioridade:
struct RemindersRequest: FetchKeyRequest {
func fetch(_ db: Database) throws -> ResultsSectionCollection {
try Reminder.order(by: \\.title).fetchAll(db, sectionBy: { $0.priority.desc() })
}
}
Como o SQLite ordena a coluna subjacente, as seções retornam em ordem de prioridade verdadeira: alta, depois média, então baixa e, por fim, os lembretes sem nenhuma prioridade.
E agora a exibição pode passar pelas seções de maneira exaustiva, sem análise stringly-typed e sem um caso default impossível para lidar:
@Fetch(RemindersRequest())
var reminders = RemindersRequest.Value()
var body: some View {
List {
ForEach(reminders) { section in
Section {
ForEach(section) { reminder in
Text(reminder.title)
}
} header: {
switch section.name {
case .high:
Label("High", systemImage: "exclamationmark.3")
.foregroundStyle(.red)
case .medium:
Label("Medium", systemImage: "exclamationmark.2")
.foregroundStyle(.orange)
case .low:
Label("Low", systemImage: "exclamationmark")
.foregroundStyle(.yellow)
case nil:
Text("No priority")
}
}
}
}
}
E isso é apenas o começo. Um FetchKeyRequest pode fazer muito mais do que agrupar uma consulta. Ele pode empacotar essas seções juntamente com qualquer número de outras consultas em uma única transação, e é isso que você usará quando a forma dos seus dados não for uma lista plana de elementos ou dados agrupados. Recentemente o utilizamos para construir um organograma que carrega toda a hierarquia de funcionários, junto com o total de relatórios diretos e transitivos para cada funcionário, em uma única expressão comum recursiva de tabela, e decodifica diretamente em uma árvore de valores que alimenta uma lista hierárquica SwiftUI List.
Exploramos tudo isso e mais no nosso último episódio, onde reconstruímos o aplicativo de amostra Trips da Apple com SQLiteData.
SQLiteData 1.8.0 está disponível hoje. Atualize suas dependências para ter acesso imediato a @FetchAll(sectionBy:) e fetchAll(sectionBy:), e nos diga o que você pensa!

