Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// MQLExportReadsRowsTests.swift
// FormatPluginTests
//

import TableProPluginKit
import Testing

struct MQLExportReadsRowsTests {
@Test("An MQL export reads a collection's documents only with Data checked")
func readsRowsOnlyWithData() {
let plugin = MQLExportPlugin()
#expect(plugin.readsRows(optionValues: [true, true, true], kind: .table))
#expect(!plugin.readsRows(optionValues: [true, true, false], kind: .table))
#expect(plugin.readsRows(optionValues: [], kind: .table))
}
}
10 changes: 8 additions & 2 deletions apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ final class MQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi
static let supportedDatabaseTypeIds = ["MongoDB"]

static let perTableOptionColumns: [PluginExportOptionColumn] = [
// The DROP statement, left untranslated: the catalog's "Drop" is the verb that deletes a table.
PluginExportOptionColumn(id: "drop", label: "Drop", width: 44),
PluginExportOptionColumn(id: "indexes", label: "Indexes", width: 44),
PluginExportOptionColumn(id: "data", label: "Data", width: 44)
PluginExportOptionColumn(id: "indexes", label: String(localized: "Indexes", bundle: .main), width: 44),
PluginExportOptionColumn(id: "data", label: String(localized: "Data", bundle: .main), width: 44)
]

typealias Settings = MQLExportOptions
Expand All @@ -41,6 +42,11 @@ final class MQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi
optionValues.contains(true)
}

// Without it, Data off still counts every collection's rows and the progress sits at 0 of N.
func readsRows(optionValues: [Bool], kind: PluginExportObjectKind) -> Bool {
optionValues.count <= 2 || optionValues[2]
}

@MainActor
func settingsView() -> AnyView? {
AnyView(MQLExportOptionsView(plugin: self))
Expand Down
5 changes: 3 additions & 2 deletions apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
static let excludedDatabaseTypeIds = ["MongoDB", "Redis"]

static let perTableOptionColumns: [PluginExportOptionColumn] = [
PluginExportOptionColumn(id: "structure", label: "Structure", width: 56),
PluginExportOptionColumn(id: "structure", label: String(localized: "Structure", bundle: .main), width: 56),
// The DROP statement, left untranslated: the catalog's "Drop" is the verb that deletes a table.
PluginExportOptionColumn(id: "drop", label: "Drop", width: 44),
PluginExportOptionColumn(id: "data", label: "Data", width: 44)
PluginExportOptionColumn(id: "data", label: String(localized: "Data", bundle: .main), width: 44)
]

static let supportedObjectKinds: [PluginExportObjectKind] = [
Expand Down Expand Up @@ -1065,7 +1066,7 @@
if let encoder { tally.unrepresentableValues += encoder.unrepresentableValues.total }
let built = SQLExportRowValueEncoder(
columns: header.columns,
columnTypeNames: header.columnTypeNames ?? [],

Check warning on line 1069 in apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift

View workflow job for this annotation

GitHub Actions / macos / macOS Tests

left side of nil coalescing operator '??' has non-optional type '[String]', so the right side is never used

Check warning on line 1069 in apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift

View workflow job for this annotation

GitHub Actions / macos / macOS Tests

left side of nil coalescing operator '??' has non-optional type '[String]', so the right side is never used
excludedColumnNames: generatedColumnNames,
databaseTypeId: dataSource.databaseTypeId,
escapeStringLiteral: dataSource.escapeStringLiteral
Expand Down
138 changes: 136 additions & 2 deletions apple/TablePro/Models/Export/ExportModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ struct ExportDatabaseItem: Identifiable {
/// Set when the container's own checkbox was turned on before its unread kinds arrived, so
/// they arrive selected like everything else the click reached.
var selectsLoadedObjects = false
// Column values set before the unread kinds arrived, by column index; they arrive with them.
var arrivingOptionValues: [Int: Bool] = [:]

init(
id: UUID = UUID(),
Expand All @@ -313,8 +315,12 @@ struct ExportDatabaseItem: Identifiable {

/// An object that arrives already selected (a restored row) stays so. Any other is selected
/// only when the container was ticked whole and nothing in it has been unticked since.
func mergingLoadedObjects(_ loaded: [ExportObjectItem]) -> ExportDatabaseItem {
let selectsArrivals = selectsLoadedObjects && objects.allSatisfy(\.isSelected)
func mergingLoadedObjects(
_ loaded: [ExportObjectItem],
writes: (ExportObjectItem) -> Bool = { _ in true }
) -> ExportDatabaseItem {
// An object that writes nothing stays out of a container tick, so it does not count as unticked.
let selectsArrivals = selectsLoadedObjects && objects.filter(writes).allSatisfy(\.isSelected)
let incoming = loaded.filter { unloadedKinds.contains($0.kind) }
// The table listing reported these sequences as tables; one relation, so the sequence replaces it.
let sequenceNames = Set(incoming.filter { $0.kind == .sequence }.map(\.name))
Expand All @@ -331,9 +337,32 @@ struct ExportDatabaseItem: Identifiable {
merged.unloadedKinds = []
merged.isLoadingObjects = false
merged.selectsLoadedObjects = false
merged.arrivingOptionValues = [:]
return merged
}

// Kept values go on after normalizing, which replaces an arrival's empty list with the defaults.
func mergingLoadedObjects(_ loaded: [ExportObjectItem], layout: ExportOptionLayout) -> ExportDatabaseItem? {
let merged = mergingLoadedObjects(loaded, writes: layout.writesSomething)
guard !merged.objects.isEmpty, let normalized = [merged].normalized(for: layout).first else { return nil }
let arrivals = Set(normalized.objects.map(\.id)).subtracting(objects.map(\.id))
return normalized.applyingOptionValues(arrivingOptionValues, toObjects: arrivals, layout: layout)
}

func applyingOptionValues(
_ values: [Int: Bool],
toObjects ids: Set<UUID>,
layout: ExportOptionLayout
) -> ExportDatabaseItem {
guard !values.isEmpty, !ids.isEmpty else { return self }
var updated = self
updated.objects = objects.map { object in
guard ids.contains(object.id) else { return object }
return object.settingOptions(values.filter { layout.supports(columnAt: $0.key, kind: object.kind) })
}
return updated
}

func withID(_ id: UUID) -> ExportDatabaseItem {
var copy = ExportDatabaseItem(
id: id,
Expand All @@ -344,6 +373,7 @@ struct ExportDatabaseItem: Identifiable {
)
copy.isLoadingObjects = isLoadingObjects
copy.selectsLoadedObjects = selectsLoadedObjects
copy.arrivingOptionValues = arrivingOptionValues
return copy
}

Expand Down Expand Up @@ -377,7 +407,75 @@ struct ExportDatabaseItem: Identifiable {
}
}

enum ExportKindReconciliation: Equatable {
case unchanged
// The kinds left after narrowing, so a wider format later reads again.
case narrowed(loadedKinds: Set<PluginExportObjectKind>)
case reload

static func next(
loadedKinds: Set<PluginExportObjectKind>,
wanted: Set<PluginExportObjectKind>
) -> ExportKindReconciliation {
guard !loadedKinds.isEmpty else { return .unchanged }
guard wanted.isSubset(of: loadedKinds) else { return .reload }
return wanted == loadedKinds ? .unchanged : .narrowed(loadedKinds: wanted)
}
}

// Resolved by the dialog so the tree never looks a plugin up itself.
struct ExportOptionLayout {
let columns: [PluginExportOptionColumn]
let defaultValues: [Bool]
// False for a structure-only format, which ignores a row scope.
let readsRows: Bool
let supports: (String, PluginExportObjectKind) -> Bool

static var none: ExportOptionLayout {
ExportOptionLayout(columns: [], defaultValues: [], readsRows: true) { _, _ in true }
}

func supports(columnAt index: Int, kind: PluginExportObjectKind) -> Bool {
columns.indices.contains(index) && supports(columns[index].id, kind)
}

func writesSomething(_ object: ExportObjectItem) -> Bool {
columns.isEmpty || object.optionValues.contains(true)
}

// Unread kinds count with the value they will arrive with.
func values(
ofColumn index: Int,
in objects: [ExportObjectItem],
unloadedKinds: Set<PluginExportObjectKind> = [],
arriving: [Int: Bool] = [:]
) -> [Bool] {
guard columns.indices.contains(index) else { return [] }
let fallback = defaultValues.count == columns.count ? defaultValues[index] : columns[index].defaultValue
var values = objects
.filter { supports(columnAt: index, kind: $0.kind) }
.map { $0.optionValues[safe: index] ?? columns[index].defaultValue }
if unloadedKinds.contains(where: { supports(columnAt: index, kind: $0) }) {
values.append(arriving[index] ?? fallback)
}
return values
}
}

extension ExportObjectItem {
// Never selects. A selected object left with nothing on is deselected: it would export nothing.
func settingOptions(_ values: [Int: Bool]) -> ExportObjectItem {
guard values.keys.contains(where: optionValues.indices.contains) else { return self }
var updated = self
for (index, value) in values where updated.optionValues.indices.contains(index) {
updated.optionValues[index] = value
}
if !updated.optionValues.contains(true) {
updated.isSelected = false
}
return updated
}

func normalized(forOptionColumnCount optionColumnCount: Int, defaultOptionValues: [Bool]) -> ExportObjectItem {
guard optionColumnCount > 0 else { return self }
let fallback = defaultOptionValues.count == optionColumnCount
Expand Down Expand Up @@ -451,6 +549,23 @@ extension [ExportDatabaseItem] {
}
}

// Masking keeps a routine from carrying a Data flag that would count it as exportable.
func normalized(for layout: ExportOptionLayout) -> [ExportDatabaseItem] {
normalizingOptionValues(optionColumnCount: layout.columns.count, defaultOptionValues: layout.defaultValues)
.maskingUnsupportedOptions(columns: layout.columns, supports: layout.supports)
}

// A profile's option values belong to its format, so the tree is reset for that format first.
func applyingProfile(
_ profile: ExportProfile,
layout: ExportOptionLayout,
databaseType: DatabaseType
) -> [ExportDatabaseItem] {
ExportProfileStorage.apply(profile, to: self)
.clearingRowScopes(unavailableOn: databaseType)
.normalized(for: layout)
}

func normalizingOptionValues(optionColumnCount: Int, defaultOptionValues: [Bool]) -> [ExportDatabaseItem] {
map { database in
var normalizedDatabase = database
Expand All @@ -464,6 +579,7 @@ extension [ExportDatabaseItem] {
func resettingOptionValues(to values: [Bool]) -> [ExportDatabaseItem] {
map { database in
var resetDatabase = database
resetDatabase.arrivingOptionValues = [:]
resetDatabase.objects = database.objects.map { object in
var resetObject = object
resetObject.optionValues = values
Expand All @@ -486,6 +602,24 @@ extension [ExportDatabaseItem] {
}
}

func settingOption(
_ index: Int,
to value: Bool,
objectIDs ids: Set<UUID>,
arrivingIn databaseID: UUID? = nil
) -> [ExportDatabaseItem] {
map { database in
var updated = database
updated.objects = database.objects.map { object in
ids.contains(object.id) ? object.settingOptions([index: value]) : object
}
if database.id == databaseID {
updated.arrivingOptionValues[index] = value
}
return updated
}
}

func clearingRowScopes(unavailableOn databaseType: DatabaseType) -> [ExportDatabaseItem] {
map { database in
var clearedDatabase = database
Expand Down
6 changes: 6 additions & 0 deletions apple/TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -57710,6 +57710,8 @@
}
},
"Data" : {
"extractionState" : "manual",
"generatesSymbol" : false,
"localizations" : {
"fr" : {
"stringUnit" : {
Expand Down Expand Up @@ -93672,6 +93674,8 @@
}
},
"Indexes" : {
"extractionState" : "manual",
"generatesSymbol" : false,
"localizations" : {
"fr" : {
"stringUnit" : {
Expand Down Expand Up @@ -173973,6 +173977,8 @@
}
},
"Structure" : {
"extractionState" : "manual",
"generatesSymbol" : false,
"localizations" : {
"fr" : {
"stringUnit" : {
Expand Down
66 changes: 61 additions & 5 deletions apple/TablePro/Views/Backup/BackupScopeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,33 +115,89 @@ final class BackupScopeModel: ObservableObject {
rows = Self.togglingObject(id, in: database, rows: rows)
}

enum Row: Equatable {
case database(String)
case object(String, database: String)
}

func toggle(_ selection: [Row]) {
rows = Self.toggling(selection, in: rows)
}

// One direction for every row, and an object under a selected database is left to it: toggling both flipped it back.
static func toggling(_ selection: [Row], in rows: [DatabaseRow]) -> [DatabaseRow] {
let databases = Set(selection.compactMap { row -> String? in
guard case .database(let name) = row else { return nil }
return name
})
let targets = selection.filter { row in
guard case .object(_, let database) = row else { return true }
return !databases.contains(database)
}
let turningOn = targets.contains { target in
switch target {
case .database(let name):
return rows.first { $0.name == name }.map { !$0.isSelected || $0.isMixed } ?? false
case .object(let id, let database):
return rows.first { $0.name == database }?.objects.first { $0.id == id }.map { !$0.isSelected } ?? false
}
}
return targets.reduce(rows) { rows, target in
switch target {
case .database(let name):
return settingDatabase(name, selected: turningOn, in: rows)
case .object(let id, let database):
return settingObject(id, in: database, selected: turningOn, rows: rows)
}
}
}

/// Ticking a mixed database means "all of it", which is the state a user reaches by unticking
/// a few tables and then changing their mind.
static func togglingDatabase(
_ database: String,
in rows: [DatabaseRow]
) -> [DatabaseRow] {
guard let row = rows.first(where: { $0.name == database }) else { return rows }
return settingDatabase(database, selected: !row.isSelected || row.isMixed, in: rows)
}

static func settingDatabase(
_ database: String,
selected: Bool,
in rows: [DatabaseRow]
) -> [DatabaseRow] {
var rows = rows
guard let index = rows.firstIndex(where: { $0.name == database }) else { return rows }
let turningOn = !rows[index].isSelected || rows[index].isMixed
rows[index].isSelected = turningOn
rows[index].isSelected = selected
for objectIndex in rows[index].objects.indices {
rows[index].objects[objectIndex].isSelected = turningOn
rows[index].objects[objectIndex].isSelected = selected
}
return rows
}

static func togglingObject(
_ id: String,
in database: String,
rows: [DatabaseRow]
) -> [DatabaseRow] {
guard let object = rows.first(where: { $0.name == database })?.objects.first(where: { $0.id == id })
else { return rows }
return settingObject(id, in: database, selected: !object.isSelected, rows: rows)
}

/// Unticking the last object is a decision to leave the database out, not a decision to back it
/// up empty. Ticking one again brings the database back carrying only that object.
static func togglingObject(
static func settingObject(
_ id: String,
in database: String,
selected: Bool,
rows: [DatabaseRow]
) -> [DatabaseRow] {
var rows = rows
guard let index = rows.firstIndex(where: { $0.name == database }) else { return rows }
guard let objectIndex = rows[index].objects.firstIndex(where: { $0.id == id }) else { return rows }
rows[index].objects[objectIndex].isSelected.toggle()
rows[index].objects[objectIndex].isSelected = selected
rows[index].isSelected = rows[index].objects.contains(where: \.isSelected)
return rows
}
Expand Down
Loading
Loading