Skip to content

Commit

Permalink
Support dictionary expression literals (#2395)
Browse files Browse the repository at this point in the history
Co-authored-by: Mai Mai <mai.mai@mapbox.com>
  • Loading branch information
evil159 and maios authored Dec 23, 2024
1 parent ce77130 commit fe94972
Show file tree
Hide file tree
Showing 7 changed files with 80 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Mapbox welcomes participation and contributions from everyone.
## main

* Localize geofencing attribution dialog.
* Support dictionary expression literals.

## 11.9.0 - 18 December, 2024

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ extension JSONDecoder.KeyDecodingStrategy {
}
}

private struct AnyKey: CodingKey {
struct AnyKey: CodingKey {
var stringValue: String
var intValue: Int?

Expand Down
9 changes: 7 additions & 2 deletions Sources/MapboxMaps/Style/Types/Expression.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ public struct Exp: Codable, CustomStringConvertible, Equatable, Sendable {
case boolean(Bool)
case numberArray([Double])
case stringArray([String])
case dictionary([String: Argument])
case option(Option)
case geoJSONObject(GeoJSONObject)
case null
Expand All @@ -197,6 +198,8 @@ public struct Exp: Codable, CustomStringConvertible, Equatable, Sendable {
return "\(array)"
case .stringArray(let stringArray):
return "\(stringArray)"
case .dictionary(let dictionary):
return "\(dictionary)"
}
}

Expand All @@ -222,6 +225,8 @@ public struct Exp: Codable, CustomStringConvertible, Equatable, Sendable {
try container.encode(array)
case .stringArray(let stringArray):
try container.encode(stringArray)
case .dictionary(let dictionary):
try container.encode(dictionary)
}
}

Expand All @@ -245,8 +250,8 @@ public struct Exp: Codable, CustomStringConvertible, Equatable, Sendable {
self = .numberArray(validArray)
} else if let validStringArray = try? container.decode([String].self) {
self = .stringArray(validStringArray)
} else if let dict = try? container.decode([String: String].self), dict.isEmpty {
self = .null
} else if let dict = try? container.decode([String: Argument].self) {
self = dict.isEmpty ? .null : .dictionary(dict)
} else {
let context = DecodingError.Context(codingPath: decoder.codingPath,
debugDescription: "Failed to decode ExpressionArgument")
Expand Down
21 changes: 13 additions & 8 deletions Sources/MapboxMaps/Style/Types/ExpressionArgumentBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,22 @@ extension Exp: ExpressionArgumentConvertible {

/// :nodoc:
/// This API enables the Expressions DSL syntax and is not designed to be called directly.
extension Dictionary: ExpressionArgumentConvertible where Key == Double,
Value: ExpressionArgumentConvertible {
extension Dictionary: ExpressionArgumentConvertible {
public var expressionArguments: [Exp.Argument] {
var arguments = [Exp.Argument]()
for key in Array(keys).sorted(by: <) {
guard key >= 0, let value = self[key] else {
fatalError("Invalid stops dictionary.")
if let stopsDictionary = self as? [Double: ExpressionArgumentConvertible] {
var arguments = [Exp.Argument]()
for key in Array(stopsDictionary.keys).sorted(by: <) {
guard key >= 0, let value = stopsDictionary[key] else {
fatalError("Invalid stops dictionary.")
}
arguments = arguments + key.expressionArguments + value.expressionArguments
}
arguments = arguments + key.expressionArguments + value.expressionArguments
return arguments
} else if let dict = self as? [String: ExpressionArgumentConvertible] {
return [.dictionary(dict.compactMapValues(\.expressionArguments.first))]
}
return arguments

return []
}
}

Expand Down
28 changes: 28 additions & 0 deletions Sources/MapboxMaps/Style/Types/ExpressionOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ public struct FormatOptions: Codable, Equatable, Sendable, ExpressionArgumentCon
}

public init() {}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
lazy var isEmptyContainer = (try? decoder.container(keyedBy: AnyKey.self).allKeys.isEmpty) ?? container.allKeys.isEmpty

guard container.containsAnyKey() ||
isEmptyContainer // Preserve behavior of format options swallowing empty dictionaries
else {
throw ExpDecodingError.noKeysFound
}

fontScale = try container.decodeIfPresent(Value<Double>.self, forKey: .fontScale)
textFont = try container.decodeIfPresent(Value<[String]>.self, forKey: .textFont)
textColor = try container.decodeIfPresent(Value<StyleColor>.self, forKey: .textColor)
}

}

public struct NumberFormatOptions: Codable, Equatable, ExpressionArgumentConvertible, Sendable {
Expand Down Expand Up @@ -155,6 +171,18 @@ public struct CollatorOptions: Codable, Equatable, ExpressionArgumentConvertible
self.diacriticSensitive = diacriticSensitive
self.locale = locale
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

guard container.containsAnyKey() else {
throw ExpDecodingError.noKeysFound
}

caseSensitive = try container.decodeIfPresent(Bool.self, forKey: .caseSensitive)
diacriticSensitive = try container.decodeIfPresent(Bool.self, forKey: .diacriticSensitive)
locale = try container.decodeIfPresent(String.self, forKey: .locale)
}
}

/// Image options container.
Expand Down
27 changes: 27 additions & 0 deletions Tests/MapboxMapsTests/Style/ExpressionTests/ExpressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -376,4 +376,31 @@ final class ExpressionTests: XCTestCase {
XCTAssertEqual(imageOptions.options["constant"], Value.constant(StyleColor(rawValue: "red")))
XCTAssertEqual(imageOptions.options["rgb"], Value.constant(StyleColor("rgba(0, 0, 0, 1)")))
}

func testEncodeLiteralDictionary() throws {
let expression = Exp(.literal) { ["opacity": 0.5] }

let encoded = try JSONEncoder().encode(expression)

XCTAssertEqual(
String(data: encoded, encoding: .utf8),
"[\"literal\",{\"opacity\":0.5}]"
)
}

func testDecodeLiteralDictionary() {
let expressionString =
"""
["literal",
{
"opacity":0.5,
"bkey":"bval",
}
]
"""

let expression = try! XCTUnwrap(JSONDecoder().decode(Expression.self, from: expressionString.data(using: .utf8)!))

XCTAssertEqual(expression, Exp(.literal) { ["opacity": 0.5, "bkey": "bval"] })
}
}
3 changes: 3 additions & 0 deletions scripts/api-compatibility-check/breakage_allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1959,3 +1959,6 @@ Var PolylineAnnotationManager.lineZOffset is now with @_spi
# Add new Radius field
Constructor LongPressInteraction.init(_:filter:action:) has been removed
Constructor TapInteraction.init(_:filter:action:) has been removed

# Expression dictionary literal support
Accessor Dictionary.expressionArguments.Get() has generic signature change from <Key, Value where Key == Swift.Double, Value : MapboxMaps.ExpressionArgumentConvertible> to <Key, Value where Key : Swift.Hashable>

1 comment on commit fe94972

@OdNairy
Copy link
Contributor

Choose a reason for hiding this comment

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

Should address the #1989

Please sign in to comment.