Skip to content

Commit c123890

Browse files
5dlawmicha
authored andcommitted
refactor(datastore): convert with schema fields (#2721)
* fix(datastore): covert with schema fields * fix: remove redundant code * fix: mark empty record with nil * fix: graphQL payload generate order * fix: recursive function break condition
1 parent e83e047 commit c123890

File tree

4 files changed

+124
-104
lines changed

4 files changed

+124
-104
lines changed

AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/ModelSchema+SQLite.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ extension ModelField: SQLColumn {
107107

108108
/// Default foreign value used to reference a model with a composite primary key.
109109
/// It's only used for the local storage, the individual values will be sent to the cloud.
110-
private func foreignKeySqlName(withAssociationTargets targetNames: [String]) -> String {
110+
func foreignKeySqlName(withAssociationTargets targetNames: [String]) -> String {
111111
// default name for legacy models without a target name
112112
if targetNames.isEmpty {
113113
return name + "Id"

AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+AnyModel.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ extension Statement {
2020
withSchema: modelSchema,
2121
using: statement,
2222
eagerLoad: eagerLoad)
23-
let untypedModel = try convertToAnyModel(using: modelSchema,
24-
modelDictionary: modelValues)
25-
models.append(untypedModel)
23+
24+
let untypedModel = try modelValues.map {
25+
try convertToAnyModel(using: modelSchema, modelDictionary: $0)
26+
}
27+
28+
if let untypedModel = untypedModel {
29+
models.append(untypedModel)
30+
}
2631
}
2732

2833
return models

AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift

Lines changed: 114 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -76,119 +76,127 @@ extension Statement: StatementModelConvertible {
7676
// parse each row of the result
7777
let iter = makeIterator()
7878
while let row = try iter.failableNext() {
79-
let modelDictionary = try convert(row: row, withSchema: modelSchema, using: statement, eagerLoad: eagerLoad)
80-
elements.append(modelDictionary)
79+
if let modelDictionary = try convert(row: row, withSchema: modelSchema, using: statement, eagerLoad: eagerLoad) {
80+
elements.append(modelDictionary)
81+
}
8182
}
8283
return elements
8384
}
84-
85-
func convert(row: Element,
86-
withSchema modelSchema: ModelSchema,
87-
using statement: SelectStatement,
88-
eagerLoad: Bool = true) throws -> ModelValues {
89-
let columnMapping = statement.metadata.columnMapping
90-
let modelDictionary = ([:] as ModelValues).mutableCopy()
91-
var skipColumns = Set<String>()
92-
var foreignKeyValues = [(String, Binding?)]()
93-
for (index, value) in row.enumerated() {
94-
let column = columnNames[index]
95-
guard let (schema, field) = columnMapping[column] else {
96-
logger.debug("[LazyLoad] Foreign key `\(column)` was found in the SQL result set with value: \(value)")
97-
foreignKeyValues.append((column, value))
98-
continue
85+
86+
func convert(
87+
row: Element,
88+
withSchema modelSchema: ModelSchema,
89+
using statement: SelectStatement,
90+
eagerLoad: Bool = true,
91+
path: [String] = []
92+
) throws -> ModelValues? {
93+
guard let maxDepth = columnNames.map({ $0.split(separator: ".").count }).max(),
94+
path.count < maxDepth
95+
else {
96+
return nil
97+
}
98+
99+
let modelValues = try modelSchema.fields.mapValues {
100+
try convert(field: $0, schema: modelSchema, using: statement, from: row, eagerLoad: eagerLoad, path: path)
101+
}
102+
103+
if modelValues.values.contains(where: { $0 != nil }) {
104+
return modelValues.merging(schemeMetadata(schema: modelSchema, element: row, path: path)) { $1 }
105+
} else {
106+
return nil
107+
}
108+
}
109+
110+
private func convert(
111+
field: ModelField,
112+
schema: ModelSchema,
113+
using statement: SelectStatement,
114+
from element: Element,
115+
eagerLoad: Bool,
116+
path: [String]
117+
) throws -> Any? {
118+
119+
switch (field.type, eagerLoad) {
120+
case (.collection, _):
121+
return convertCollection(field: field, schema: schema, from: element, path: path)
122+
123+
case (.model, false):
124+
let foreignKey = field.association.map(getTargetNames).map {
125+
field.foreignKeySqlName(withAssociationTargets: $0)
99126
}
100-
101-
let modelValue = try SQLiteModelValueConverter.convertToSource(
102-
from: value,
103-
fieldType: field.type
104-
)
105-
// Check if the value for the primary key is `nil`. This is when an associated model does not exist.
106-
// To create a decodable `modelDictionary` that can be decoded to the Model types, the entire
107-
// object at this particular key should be set to `nil`. The following code does that by dropping the last
108-
// path from the column, for example given "blog.id" `column` has a nil `modelValue`, then store the
109-
// keypath in `skipColumns`, which will be used to set modelDictionary["blog"] to nil later on.
110-
//
111-
// `skipColumns` keeps track of these scenarios. At this point in the code we cannot make an assumption
112-
// about the ordering of the columns, it may handle "blog.description", then "blog.id", then "blog.title".
113-
// The code will perform the following:
114-
// 1. set ["blog"]["description"] = `nil`, because it has not encountered `nil` primary key
115-
// 2. store skipColumn["blog"], because the value is the primary key and is nil
116-
// 3. skip setting modelDictionary["blog"]["title"], because `skipColumn` has been set for "blog".
117-
if field.isPrimaryKey && modelValue == nil {
118-
let keyPathParent = column.dropLastPath()
119-
skipColumns.insert(keyPathParent)
127+
let targetBinding = foreignKey.flatMap {
128+
getValue(from: element, by: path + [$0])
120129
}
130+
return DataStoreModelDecoder.lazyInit(identifier: targetBinding)
121131

122-
if skipColumns.isEmpty {
123-
modelDictionary.updateValue(modelValue, forKeyPath: column)
124-
} else {
125-
let keyPathParent = column.dropLastPath()
126-
if !skipColumns.contains(keyPathParent) {
127-
modelDictionary.updateValue(modelValue, forKeyPath: column)
128-
}
132+
case let (.model(modelName), true):
133+
guard let modelSchema = getModelSchema(for: modelName, with: statement)
134+
else {
135+
return nil
129136
}
130137

131-
// create lazy list for "many" relationships
132-
// this code only executes once when the `id` is the primary key of the current model
133-
// and runs in an iteration over all of the associations
134-
// For example, when the value is the `id` of the Blog, then the field.isPrimaryKey is satisfied.
135-
// Every association of the Blog, such as the has-many Post is populated with the List with
136-
// associatedId == blog's id. This way, the list of post can be lazily loaded later using the associated id.
137-
if let id = modelValue as? String,
138-
(field.name == ModelIdentifierFormat.Custom.sqlColumnName || // this is the `@@primaryKey` (CPK)
139-
(schema.primaryKey.fields.count == 1 // or there's only one primary key (not composite key)
140-
&& schema.primaryKey.indexOfField(named: field.name) != nil)) { // and this field is the primary key
141-
let associations = schema.fields.values.filter {
142-
$0.isArray && $0.hasAssociation
143-
}
144-
let prefix = column.replacingOccurrences(of: field.name, with: "")
145-
associations.forEach { association in
146-
let associatedField = association.associatedField?.name
147-
let lazyList = DataStoreListDecoder.lazyInit(associatedId: id,
148-
associatedWith: associatedField)
149-
let listKeyPath = prefix + association.name
150-
modelDictionary.updateValue(lazyList, forKeyPath: listKeyPath)
151-
}
138+
return try convert(
139+
row: element,
140+
withSchema: modelSchema,
141+
using: statement,
142+
eagerLoad: eagerLoad,
143+
path: path + [field.name])
144+
145+
default:
146+
let value = getValue(from: element, by: path + [field.name])
147+
return try SQLiteModelValueConverter.convertToSource(from: value, fieldType: field.type)
148+
}
149+
}
150+
151+
private func getModelSchema(for modelName: ModelName, with statement: SelectStatement) -> ModelSchema? {
152+
return statement.metadata.columnMapping.values.first { $0.0.name == modelName }.map { $0.0 }
153+
}
154+
155+
private func convertCollection(field: ModelField, schema: ModelSchema, from element: Element, path: [String]) -> Any? {
156+
if field.isArray && field.hasAssociation,
157+
case let .some(.hasMany(associatedFieldName: associatedFieldName)) = field.association
158+
{
159+
let primeryKeyName = schema.primaryKey.isCompositeKey
160+
? ModelIdentifierFormat.Custom.sqlColumnName
161+
: schema.primaryKey.fields.first.flatMap { $0.name }
162+
let primeryKeyValue = primeryKeyName.flatMap { getValue(from: element, by: path + [$0]) }
163+
164+
return primeryKeyValue.map {
165+
DataStoreListDecoder.lazyInit(associatedId: String(describing: $0), associatedWith: associatedFieldName)
152166
}
153167
}
154168

155-
// the `skipColumns` is sorted from longest to shortest since we eager load all belongs-to, for the example of
156-
// a Comment, that belongs to a Post, that belongs to a Blog. Comment has nil Post and nil Blog. The
157-
// `skipColumns` will be populated as
158-
// - skipColumns["post"]
159-
// - skipColumns["post.blog"]
160-
//
161-
// By ordering it as "post.blog" then "post", before setting the `nil` values for those keypaths, then the
162-
// shortest keypath will the last key in the `modelDictionary`. modelDictionary["post"]["blog"] will be
163-
// replaced with modelDictionary["post"] = nil.
164-
//
165-
// If there does exist a post but no blog on the post, then the following `skipColumns` would be populated:
166-
// - skipColumns["post.blog"] (since only primary key for blog does not exist, post object exists)
167-
// and the resulting modelDictionary will be populated:
168-
// modelDictionary["post"]["id"] = <ID>
169-
// modelDictionary["post"][<remaining required/optional fields of post>] = <values>
170-
// modelDictionary["post"]["blog"] = nil
171-
let sortedColumns = skipColumns.sorted(by: { $0.count > $1.count })
172-
for skipColumn in sortedColumns {
173-
modelDictionary.updateValue(nil, forKeyPath: skipColumn)
169+
return nil
170+
}
171+
172+
private func getTargetNames(assoication: ModelAssociation) -> [String] {
173+
switch assoication {
174+
case let .hasOne(associatedFieldName: _, targetNames: targets):
175+
return targets
176+
case let.belongsTo(associatedFieldName: _, targetNames: targets):
177+
return targets
178+
case .hasMany:
179+
return []
174180
}
175-
modelDictionary["__typename"] = modelSchema.name
176-
177-
// `foreignKeyValues` are all foreign keys and their values that can be added to the object for lazy loading
178-
// belongs to associations.
179-
if !eagerLoad {
180-
for foreignKeyValue in foreignKeyValues {
181-
let foreignColumnName = foreignKeyValue.0
182-
if let foreignModelField = modelSchema.foreignKeys.first(where: { modelField in
183-
modelField.sqlName == foreignColumnName
184-
}) {
185-
modelDictionary[foreignModelField.name] = DataStoreModelDecoder.lazyInit(identifier: foreignKeyValue.1)
186-
}
187-
}
181+
}
182+
183+
private func getValue(from element: Element, by path: [String]) -> Binding? {
184+
return columnNames.firstIndex { $0 == path.fieldPath }
185+
.flatMap { element[$0] }
186+
}
187+
188+
private func schemeMetadata(schema: ModelSchema, element: Element, path: [String]) -> ModelValues {
189+
var metadata = [
190+
"__typename": schema.name,
191+
]
192+
193+
if schema.primaryKey.isCompositeKey,
194+
let compositeKey = getValue(from: element, by: path + [ModelIdentifierFormat.Custom.sqlColumnName])
195+
{
196+
metadata.updateValue(String(describing: compositeKey), forKey: ModelIdentifierFormat.Custom.sqlColumnName)
188197
}
189-
190-
// swiftlint:disable:next force_cast
191-
return modelDictionary as! ModelValues
198+
199+
return metadata
192200
}
193201
}
194202

@@ -285,3 +293,9 @@ extension String {
285293
}
286294
}
287295
}
296+
297+
extension Array where Element == String {
298+
var fieldPath: String {
299+
self.filter { !$0.isEmpty }.joined(separator: ".")
300+
}
301+
}

AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginLazyLoadTests/LL12/HasOneParentChild/AWSDataStoreLazyLoadHasOneTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class AWSDataStoreLazyLoadHasOneTests: AWSDataStoreLazyLoadBaseTest {
3838
let parent = HasOneParent(child: child)
3939
let savedParent = try await saveAndWaitForSync(parent)
4040
}
41+
4142
}
4243

4344
extension AWSDataStoreLazyLoadHasOneTests {

0 commit comments

Comments
 (0)