├── .gitignore ├── .swift-version ├── CODE_OF_CONDUCT.md ├── CoreDataQueryInterface.png ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── CoreDataQueryInterface │ ├── Attributed.swift │ ├── NSExpressionDescription.swift │ ├── NSManagedObject.swift │ ├── NSManagedObjectContext.swift │ ├── NSManagedObjectID.swift │ ├── NSSortDescriptor.swift │ ├── QueryBuilder+Fetch.swift │ ├── QueryBuilder+Filter.swift │ ├── QueryBuilder+Group.swift │ ├── QueryBuilder+Group.swift.gyb │ ├── QueryBuilder+Order.swift │ ├── QueryBuilder+Order.swift.gyb │ ├── QueryBuilder+Select.swift │ ├── QueryBuilder+Select.swift.gyb │ └── QueryBuilder.swift ├── Tests └── CoreDataQueryInterfaceTests │ ├── Developer.swift │ ├── Developers.xcdatamodeld │ └── Model.xcdatamodel │ │ └── contents │ ├── Language.swift │ ├── QueryBuilderFetchTests.swift │ ├── QueryBuilderFilterTests.swift │ ├── QueryBuilderGroupTests.swift │ ├── QueryBuilderOrderTests.swift │ ├── QueryBuilderSelectTests.swift │ ├── QueryBuilderTests.swift │ └── Store.swift ├── Vendor ├── get-gyb ├── gyb └── gyb.py └── mktemplates /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .swiftpm 3 | .build 4 | __pycache__ 5 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 4.1 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | This project adheres to No Code of Conduct. We are all adults. We accept anyone's contributions. Nothing else matters. 4 | 5 | For more information please visit the [No Code of Conduct](https://github.com/domgetter/NCoC) homepage. 6 | -------------------------------------------------------------------------------- /CoreDataQueryInterface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prosumma/CoreDataQueryInterface/0eea4c64803bdb8d03debb3ea468efcab4d31a47/CoreDataQueryInterface.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 - 2022 Gregory Higley (Prosumma) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "predicateqi", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/prosumma/PredicateQI", 7 | "state" : { 8 | "revision" : "4730ae50948cd133cb386907cbce22bb7f2e1380", 9 | "version" : "1.0.9" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CoreDataQueryInterface", 8 | platforms: [.macOS(.v13), .iOS(.v15), .tvOS(.v15), .watchOS(.v8)], 9 | products: [ 10 | .library( 11 | name: "CoreDataQueryInterface", 12 | targets: ["CoreDataQueryInterface"] 13 | ), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/prosumma/PredicateQI", from: "1.0.0") 17 | ], 18 | targets: [ 19 | .target( 20 | name: "CoreDataQueryInterface", 21 | dependencies: ["PredicateQI"], 22 | exclude: [ 23 | "QueryBuilder+Group.swift.gyb", 24 | "QueryBuilder+Order.swift.gyb", 25 | "QueryBuilder+Select.swift.gyb" 26 | ] 27 | ), 28 | .testTarget( 29 | name: "CoreDataQueryInterfaceTests", 30 | dependencies: ["CoreDataQueryInterface"], 31 | resources: [.copy("Developers.xcdatamodeld")] 32 | ), 33 | ], 34 | swiftLanguageVersions: [.v5] 35 | ) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CoreDataQueryInterface](CoreDataQueryInterface.png) 2 | 3 | [![Language](https://img.shields.io/badge/Swift-5.7-blue.svg)](http://swift.org) 4 | 5 | Core Data Query Interface (CDQI) is a type-safe, fluent, intuitive library for working with Core Data in Swift. CDQI tremendously reduces the amount of code needed to do Core Data, and dramatically improves readability and refactoring by allowing method chaining and by eliminating magic strings. 6 | 7 | CDQI uses the [PredicateQI](https://github.com/prosumma/PredicateQI) (PQI) package. PQI provides a type-safe Swift interface on top of Apple's `NSPredicate` language. CDQI adds the machinery needed to make PQI work with Core Data. 8 | 9 | ## Features 10 | 11 | - [x] [Fluent interface](http://en.wikipedia.org/wiki/Fluent_interface), i.e., chainable methods 12 | - [x] Large number of useful overloads 13 | - [x] Type-safety — really, type guidance (see below) — in filter comparisons. 14 | - [x] Filtering, sorting, grouping, aggregate expressions, limits, etc. 15 | - [x] Optionally eliminates the use of magic strings so common in Core Data 16 | - [x] Query reuse, i.e., no side-effects from chaining 17 | - [x] macOS 12+, iOS 15+, tvOS 15+, watchOS 8+ 18 | - [x] Swift 5.7 19 | 20 | ## Overview 21 | 22 | The best way to understand how to use CDQI is to look at the unit tests, but an example will make things clear. 23 | 24 | First, let's start with the following data model: 25 | 26 | ```swift 27 | class Language: NSManagedObject { 28 | @NSManaged var name: String 29 | @NSManaged var developers: Set 30 | } 31 | 32 | class Developer: NSManagedObject { 33 | @NSManaged var firstName: String 34 | @NSManaged var lastName: String 35 | @NSManaged var languages: Set 36 | } 37 | ``` 38 | 39 | Given this data model, we can start making some queries. But first, remember to `import PredicateQI`! Without this import, the compiler magic that allows the comparison expressions to be translated into the `NSPredicate` language won't work and in most cases you'll get mysterious compilation errors. 40 | 41 | ```swift 42 | import CoreDataQueryInterface 43 | import PredicateQI 44 | 45 | // Which languages are known by at least two of the developers? 46 | // developers.@count >= 2 47 | Query(Language.self).filter { $0.developers.pqiCount >= 2 } 48 | 49 | // Which languages are known by developers whose name contains 's'? 50 | // ANY developers.lastname LIKE[c] '*s*' 51 | Query(Language.self).filter { any(ci($0.developers.lastName %* "*s*")) } 52 | ``` 53 | 54 | We can get the `NSFetchRequest` produced by the query by asking for its `fetchRequest` property. But it's usually easier just to execute the fetch request directly: 55 | 56 | ```swift 57 | import CoreDataQueryInterface 58 | import PredicateQI 59 | 60 | let cooldevs = try Query(Developer.self) 61 | .filter { 62 | // ANY languages.name IN {"Rust","Haskell"} 63 | any($0.languages.name <~| ["Rust", "Haskell"]) 64 | } 65 | .fetch(moc) 66 | ``` 67 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/Attributed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Attributed.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Greg Higley on 2023-05-13. 6 | // 7 | 8 | import CoreData 9 | import PredicateQI 10 | 11 | public protocol Attributed { 12 | static var attributeType: NSAttributeDescription.AttributeType { get } 13 | } 14 | 15 | extension TypedExpression: Attributed where T: Attributed { 16 | public static var attributeType: NSAttributeDescription.AttributeType { 17 | T.attributeType 18 | } 19 | } 20 | 21 | extension Bool: Attributed { 22 | public static let attributeType: NSAttributeDescription.AttributeType = .boolean 23 | } 24 | 25 | extension Data: Attributed { 26 | public static let attributeType: NSAttributeDescription.AttributeType = .binaryData 27 | } 28 | 29 | extension Date: Attributed { 30 | public static let attributeType: NSAttributeDescription.AttributeType = .date 31 | } 32 | 33 | extension String: Attributed { 34 | public static let attributeType: NSAttributeDescription.AttributeType = .string 35 | } 36 | 37 | extension Int16: Attributed { 38 | public static let attributeType: NSAttributeDescription.AttributeType = .integer16 39 | } 40 | 41 | extension Int32: Attributed { 42 | public static let attributeType: NSAttributeDescription.AttributeType = .integer32 43 | } 44 | 45 | extension Int64: Attributed { 46 | public static let attributeType: NSAttributeDescription.AttributeType = .integer64 47 | } 48 | 49 | extension Decimal: Attributed { 50 | public static let attributeType: NSAttributeDescription.AttributeType = .decimal 51 | } 52 | 53 | extension Double: Attributed { 54 | public static let attributeType: NSAttributeDescription.AttributeType = .double 55 | } 56 | 57 | extension Float: Attributed { 58 | public static let attributeType: NSAttributeDescription.AttributeType = .float 59 | } 60 | 61 | extension NSManagedObjectID: Attributed { 62 | public static let attributeType: NSAttributeDescription.AttributeType = .objectID 63 | } 64 | 65 | extension UUID: Attributed { 66 | public static let attributeType: NSAttributeDescription.AttributeType = .uuid 67 | } 68 | 69 | extension URL: Attributed { 70 | public static let attributeType: NSAttributeDescription.AttributeType = .uri 71 | } 72 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/NSExpressionDescription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSExpressionDescription.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-25. 6 | // 7 | 8 | import CoreData 9 | import PredicateQI 10 | 11 | public extension NSExpressionDescription { 12 | convenience init( 13 | objectKeyPath keyPath: KeyPath, V>, 14 | name: String? = nil, 15 | type: NSAttributeDescription.AttributeType? = nil 16 | ) { 17 | self.init() 18 | let expression = Object()[keyPath: keyPath].pqiExpression 19 | self.expression = expression 20 | if let name = name { 21 | self.name = name 22 | } else if expression.expressionType == .keyPath, let name = expression.keyPath.split(separator: ".").last { 23 | self.name = String(name) 24 | } 25 | if let type = type { 26 | self.resultType = type 27 | } else if let a = V.self as? Attributed.Type { 28 | self.resultType = a.attributeType 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/NSManagedObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObject.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | import PredicateQI 10 | 11 | extension NSManagedObject: TypeComparable { 12 | public typealias PQIComparisonType = NSManagedObjectID 13 | } 14 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/NSManagedObjectContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObjectContext.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | 10 | public extension NSManagedObjectContext { 11 | func query(_ entityType: M.Type) -> QueryBuilder { 12 | Query(entityType).context(self) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/NSManagedObjectID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObjectID.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | import PredicateQI 10 | 11 | extension NSManagedObjectID: ConstantExpression, TypeComparable {} 12 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/NSSortDescriptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSSortDescriptor.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-25. 6 | // 7 | 8 | import Foundation 9 | import PredicateQI 10 | 11 | public extension NSSortDescriptor { 12 | convenience init(objectKeyPath keyPath: KeyPath, V>, ascending: Bool) { 13 | let expression = Object()[keyPath: keyPath].pqiExpression 14 | self.init(key: expression.keyPath, ascending: ascending) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/QueryBuilder+Fetch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder+Fetch.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | 10 | public extension QueryBuilder { 11 | var fetchRequest: NSFetchRequest { 12 | let fetchRequest = NSFetchRequest(entityName: M.entity().name!) 13 | fetchRequest.returnsDistinctResults = returnsDistinctResults 14 | fetchRequest.fetchLimit = fetchLimit 15 | fetchRequest.fetchOffset = fetchOffset 16 | if !predicates.isEmpty { 17 | fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) 18 | } 19 | if !propertiesToFetch.isEmpty { 20 | let properties = propertiesToFetch.map(\.asAny) 21 | fetchRequest.propertiesToFetch = properties 22 | } 23 | if !propertiesToGroupBy.isEmpty { 24 | let properties = propertiesToGroupBy.map(\.asAny) 25 | fetchRequest.propertiesToGroupBy = properties 26 | } 27 | if !sortDescriptors.isEmpty { 28 | fetchRequest.sortDescriptors = sortDescriptors 29 | } 30 | let resultType: NSFetchRequestResultType 31 | switch R.self { 32 | case is NSManagedObject.Type: 33 | resultType = .managedObjectResultType 34 | case is NSManagedObjectID.Type: 35 | resultType = .managedObjectIDResultType 36 | case is NSDictionary.Type: 37 | resultType = .dictionaryResultType 38 | case is NSNumber.Type: 39 | // TODO: Figure out what the hell this is. 40 | resultType = .countResultType // ??? 41 | default: 42 | resultType = .managedObjectResultType 43 | } 44 | fetchRequest.resultType = resultType 45 | return fetchRequest 46 | } 47 | 48 | func limit(_ fetchLimit: Int) -> QueryBuilder { 49 | var query = self 50 | query.fetchLimit = fetchLimit 51 | return query 52 | } 53 | 54 | func offset(_ fetchOffset: Int) -> QueryBuilder { 55 | var query = self 56 | query.fetchOffset = fetchOffset 57 | return query 58 | } 59 | 60 | func objects() -> QueryBuilder { 61 | .init(copying: self) 62 | } 63 | 64 | func ids() -> QueryBuilder { 65 | .init(copying: self) 66 | } 67 | 68 | func dictionaries() -> QueryBuilder { 69 | .init(copying: self) 70 | } 71 | 72 | func fetch(_ managedObjectContext: NSManagedObjectContext? = nil) throws -> [R] { 73 | let results: [R] 74 | if let moc = self.managedObjectContext ?? managedObjectContext { 75 | results = try moc.fetch(fetchRequest) 76 | } else { 77 | results = try fetchRequest.execute() 78 | } 79 | return results 80 | } 81 | 82 | func fetchObjects(_ managedObjectContext: NSManagedObjectContext? = nil) throws -> [M] { 83 | try objects().fetch(managedObjectContext) 84 | } 85 | 86 | func fetchIDs(_ managedObjectContext: NSManagedObjectContext? = nil) throws -> [NSManagedObjectID] { 87 | try ids().fetch(managedObjectContext) 88 | } 89 | 90 | func fetchDictionaries(_ managedObjectContext: NSManagedObjectContext? = nil) throws -> [[String: Any]] { 91 | try dictionaries().fetch(managedObjectContext) as! [[String: Any]] 92 | } 93 | 94 | func fetchFirst(_ managedObjectContext: NSManagedObjectContext? = nil) throws -> R? { 95 | try limit(1).fetch(managedObjectContext).first 96 | } 97 | 98 | func count(_ managedObjectContext: NSManagedObjectContext? = nil) throws -> Int { 99 | guard let moc = self.managedObjectContext ?? managedObjectContext else { 100 | preconditionFailure("No NSManagedObjectContext instance on which to execute the request.") 101 | } 102 | return try moc.count(for: fetchRequest) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/QueryBuilder+Filter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder+Filter.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import Foundation 9 | import PredicateQI 10 | 11 | public extension QueryBuilder { 12 | func filter(_ predicate: NSPredicate) -> QueryBuilder { 13 | var query = self 14 | query.predicates.append(predicate) 15 | return query 16 | } 17 | 18 | func filter(_ format: String, _ args: Any...) -> QueryBuilder { 19 | filter(NSPredicate(format: format, argumentArray: args)) 20 | } 21 | 22 | func filter(_ isIncluded: (O) -> PredicateBuilder) -> QueryBuilder { 23 | filter(pred(isIncluded(.init()))) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/QueryBuilder+Group.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder+Group.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-24. 6 | // 7 | 8 | import CoreData 9 | import PredicateQI 10 | import XCTest 11 | 12 | public extension QueryBuilder { 13 | func group(by properties: [NSPropertyDescription]) -> QueryBuilder { 14 | group(by: properties.map(FetchedProperty.property)) 15 | } 16 | 17 | func group(by properties: NSPropertyDescription...) -> QueryBuilder { 18 | group(by: properties) 19 | } 20 | 21 | func group(by properties: [String]) -> QueryBuilder { 22 | group(by: properties.map(FetchedProperty.string)) 23 | } 24 | 25 | func group(by properties: String...) -> QueryBuilder { 26 | group(by: properties) 27 | } 28 | 29 | func group(by keyPath: KeyPath, name: String? = nil, type: NSAttributeDescription.AttributeType? = nil) -> QueryBuilder { 30 | return group(by: NSExpressionDescription(objectKeyPath: keyPath, name: name, type: type)) 31 | } 32 | 33 | func group(by keyPath: KeyPath) -> QueryBuilder { 34 | let object = O() 35 | let expression = object[keyPath: keyPath] 36 | return group(by: "\(expression.pqiExpression)") 37 | } 38 | 39 | func group( 40 | by keyPath1: KeyPath, 41 | _ keyPath2: KeyPath 42 | ) -> QueryBuilder { 43 | group(by: keyPath1) 44 | .group(by: keyPath2) 45 | } 46 | 47 | func group( 48 | by keyPath1: KeyPath, 49 | _ keyPath2: KeyPath, 50 | _ keyPath3: KeyPath 51 | ) -> QueryBuilder { 52 | group(by: keyPath1) 53 | .group(by: keyPath2) 54 | .group(by: keyPath3) 55 | } 56 | 57 | func group( 58 | by keyPath1: KeyPath, 59 | _ keyPath2: KeyPath, 60 | _ keyPath3: KeyPath, 61 | _ keyPath4: KeyPath 62 | ) -> QueryBuilder { 63 | group(by: keyPath1) 64 | .group(by: keyPath2) 65 | .group(by: keyPath3) 66 | .group(by: keyPath4) 67 | } 68 | 69 | func group( 70 | by keyPath1: KeyPath, 71 | _ keyPath2: KeyPath, 72 | _ keyPath3: KeyPath, 73 | _ keyPath4: KeyPath, 74 | _ keyPath5: KeyPath 75 | ) -> QueryBuilder { 76 | group(by: keyPath1) 77 | .group(by: keyPath2) 78 | .group(by: keyPath3) 79 | .group(by: keyPath4) 80 | .group(by: keyPath5) 81 | } 82 | 83 | func group( 84 | by keyPath1: KeyPath, 85 | _ keyPath2: KeyPath, 86 | _ keyPath3: KeyPath, 87 | _ keyPath4: KeyPath, 88 | _ keyPath5: KeyPath, 89 | _ keyPath6: KeyPath 90 | ) -> QueryBuilder { 91 | group(by: keyPath1) 92 | .group(by: keyPath2) 93 | .group(by: keyPath3) 94 | .group(by: keyPath4) 95 | .group(by: keyPath5) 96 | .group(by: keyPath6) 97 | } 98 | 99 | func group( 100 | by keyPath1: KeyPath, 101 | _ keyPath2: KeyPath, 102 | _ keyPath3: KeyPath, 103 | _ keyPath4: KeyPath, 104 | _ keyPath5: KeyPath, 105 | _ keyPath6: KeyPath, 106 | _ keyPath7: KeyPath 107 | ) -> QueryBuilder { 108 | group(by: keyPath1) 109 | .group(by: keyPath2) 110 | .group(by: keyPath3) 111 | .group(by: keyPath4) 112 | .group(by: keyPath5) 113 | .group(by: keyPath6) 114 | .group(by: keyPath7) 115 | } 116 | 117 | private func group(by properties: [FetchedProperty]) -> QueryBuilder { 118 | var query = self 119 | query.propertiesToGroupBy.append(contentsOf: properties) 120 | return query 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/QueryBuilder+Group.swift.gyb: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder+Group.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-24. 6 | // 7 | %{ 8 | def args(items, fmt=lambda i: f'{i}', sep=', '): 9 | return sep.join(map(fmt, items)) 10 | }% 11 | 12 | import PredicateQI 13 | import XCTest 14 | 15 | public extension QueryBuilder { 16 | func group(by properties: [NSPropertyDescription]) -> QueryBuilder { 17 | group(by: properties.map(FetchedProperty.property)) 18 | } 19 | 20 | func group(by properties: NSPropertyDescription...) -> QueryBuilder { 21 | group(by: properties) 22 | } 23 | 24 | func group(by properties: [String]) -> QueryBuilder { 25 | group(by: properties.map(FetchedProperty.string)) 26 | } 27 | 28 | func group(by properties: String...) -> QueryBuilder { 29 | group(by: properties) 30 | } 31 | 32 | func group(by keyPath: KeyPath, name: String? = nil, type: NSAttributeDescription.AttributeType? = nil) -> QueryBuilder { 33 | return group(by: NSExpressionDescription(objectKeyPath: keyPath, name: name, type: type)) 34 | } 35 | 36 | func group(by keyPath: KeyPath) -> QueryBuilder { 37 | let object = O() 38 | let expression = object[keyPath: keyPath] 39 | return group(by: "\(expression.pqiExpression)") 40 | } 41 | % for i in range(2, 8): 42 | 43 | func group<${args(range(1, i + 1), lambda i: f'V{i}: E')}>( 44 | by ${args(range(1, i + 1), lambda i: f'keyPath{i}: KeyPath', ',\n _ ')} 45 | ) -> QueryBuilder { 46 | group(by: keyPath1) 47 | % for k in range(2, i + 1): 48 | .group(by: keyPath${k}) 49 | % end 50 | } 51 | % end 52 | 53 | private func group(by properties: [FetchedProperty]) -> QueryBuilder { 54 | var query = self 55 | query.propertiesToGroupBy.append(contentsOf: properties) 56 | return query 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/QueryBuilder+Order.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder+Order.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import Foundation 9 | import PredicateQI 10 | 11 | public extension QueryBuilder { 12 | enum SortDirection { 13 | case ascending, descending 14 | 15 | var isAscending: Bool { 16 | self == .ascending 17 | } 18 | } 19 | 20 | func order(by sortDescriptors: [NSSortDescriptor]) -> QueryBuilder { 21 | var query = self 22 | query.sortDescriptors.append(contentsOf: sortDescriptors) 23 | return query 24 | } 25 | 26 | func order(by sortDescriptors: NSSortDescriptor...) -> QueryBuilder { 27 | order(by: sortDescriptors) 28 | } 29 | 30 | func order(_ direction: SortDirection = .ascending, by keyPath: KeyPath) -> QueryBuilder { 31 | order(by: .init(objectKeyPath: keyPath, ascending: direction.isAscending)) 32 | } 33 | 34 | func order( 35 | _ direction: SortDirection = .ascending, 36 | by keyPath1: KeyPath, 37 | _ keyPath2: KeyPath 38 | ) -> QueryBuilder { 39 | let sortDescriptors = [ 40 | NSSortDescriptor(objectKeyPath: keyPath1, ascending: direction.isAscending), 41 | NSSortDescriptor(objectKeyPath: keyPath2, ascending: direction.isAscending), 42 | ] 43 | return order(by: sortDescriptors) 44 | } 45 | 46 | func order( 47 | _ direction: SortDirection = .ascending, 48 | by keyPath1: KeyPath, 49 | _ keyPath2: KeyPath, 50 | _ keyPath3: KeyPath 51 | ) -> QueryBuilder { 52 | let sortDescriptors = [ 53 | NSSortDescriptor(objectKeyPath: keyPath1, ascending: direction.isAscending), 54 | NSSortDescriptor(objectKeyPath: keyPath2, ascending: direction.isAscending), 55 | NSSortDescriptor(objectKeyPath: keyPath3, ascending: direction.isAscending), 56 | ] 57 | return order(by: sortDescriptors) 58 | } 59 | 60 | func order( 61 | _ direction: SortDirection = .ascending, 62 | by keyPath1: KeyPath, 63 | _ keyPath2: KeyPath, 64 | _ keyPath3: KeyPath, 65 | _ keyPath4: KeyPath 66 | ) -> QueryBuilder { 67 | let sortDescriptors = [ 68 | NSSortDescriptor(objectKeyPath: keyPath1, ascending: direction.isAscending), 69 | NSSortDescriptor(objectKeyPath: keyPath2, ascending: direction.isAscending), 70 | NSSortDescriptor(objectKeyPath: keyPath3, ascending: direction.isAscending), 71 | NSSortDescriptor(objectKeyPath: keyPath4, ascending: direction.isAscending), 72 | ] 73 | return order(by: sortDescriptors) 74 | } 75 | 76 | func order( 77 | _ direction: SortDirection = .ascending, 78 | by keyPath1: KeyPath, 79 | _ keyPath2: KeyPath, 80 | _ keyPath3: KeyPath, 81 | _ keyPath4: KeyPath, 82 | _ keyPath5: KeyPath 83 | ) -> QueryBuilder { 84 | let sortDescriptors = [ 85 | NSSortDescriptor(objectKeyPath: keyPath1, ascending: direction.isAscending), 86 | NSSortDescriptor(objectKeyPath: keyPath2, ascending: direction.isAscending), 87 | NSSortDescriptor(objectKeyPath: keyPath3, ascending: direction.isAscending), 88 | NSSortDescriptor(objectKeyPath: keyPath4, ascending: direction.isAscending), 89 | NSSortDescriptor(objectKeyPath: keyPath5, ascending: direction.isAscending), 90 | ] 91 | return order(by: sortDescriptors) 92 | } 93 | 94 | func order( 95 | _ direction: SortDirection = .ascending, 96 | by keyPath1: KeyPath, 97 | _ keyPath2: KeyPath, 98 | _ keyPath3: KeyPath, 99 | _ keyPath4: KeyPath, 100 | _ keyPath5: KeyPath, 101 | _ keyPath6: KeyPath 102 | ) -> QueryBuilder { 103 | let sortDescriptors = [ 104 | NSSortDescriptor(objectKeyPath: keyPath1, ascending: direction.isAscending), 105 | NSSortDescriptor(objectKeyPath: keyPath2, ascending: direction.isAscending), 106 | NSSortDescriptor(objectKeyPath: keyPath3, ascending: direction.isAscending), 107 | NSSortDescriptor(objectKeyPath: keyPath4, ascending: direction.isAscending), 108 | NSSortDescriptor(objectKeyPath: keyPath5, ascending: direction.isAscending), 109 | NSSortDescriptor(objectKeyPath: keyPath6, ascending: direction.isAscending), 110 | ] 111 | return order(by: sortDescriptors) 112 | } 113 | 114 | func order( 115 | _ direction: SortDirection = .ascending, 116 | by keyPath1: KeyPath, 117 | _ keyPath2: KeyPath, 118 | _ keyPath3: KeyPath, 119 | _ keyPath4: KeyPath, 120 | _ keyPath5: KeyPath, 121 | _ keyPath6: KeyPath, 122 | _ keyPath7: KeyPath 123 | ) -> QueryBuilder { 124 | let sortDescriptors = [ 125 | NSSortDescriptor(objectKeyPath: keyPath1, ascending: direction.isAscending), 126 | NSSortDescriptor(objectKeyPath: keyPath2, ascending: direction.isAscending), 127 | NSSortDescriptor(objectKeyPath: keyPath3, ascending: direction.isAscending), 128 | NSSortDescriptor(objectKeyPath: keyPath4, ascending: direction.isAscending), 129 | NSSortDescriptor(objectKeyPath: keyPath5, ascending: direction.isAscending), 130 | NSSortDescriptor(objectKeyPath: keyPath6, ascending: direction.isAscending), 131 | NSSortDescriptor(objectKeyPath: keyPath7, ascending: direction.isAscending), 132 | ] 133 | return order(by: sortDescriptors) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/QueryBuilder+Order.swift.gyb: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder+Order.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | %{ 8 | def args(items, fmt=lambda i: f'{i}', sep=', '): 9 | return sep.join(map(fmt, items)) 10 | }% 11 | 12 | import Foundation 13 | import PredicateQI 14 | 15 | public extension QueryBuilder { 16 | enum SortDirection { 17 | case ascending, descending 18 | 19 | var isAscending: Bool { 20 | self == .ascending 21 | } 22 | } 23 | 24 | func order(by sortDescriptors: [NSSortDescriptor]) -> QueryBuilder { 25 | var query = self 26 | query.sortDescriptors.append(contentsOf: sortDescriptors) 27 | return query 28 | } 29 | 30 | func order(by sortDescriptors: NSSortDescriptor...) -> QueryBuilder { 31 | order(by: sortDescriptors) 32 | } 33 | 34 | func order(_ direction: SortDirection = .ascending, by keyPath: KeyPath) -> QueryBuilder { 35 | order(by: .init(objectKeyPath: keyPath, ascending: direction.isAscending)) 36 | } 37 | % for i in range(2, 8): 38 | 39 | func order<${args(range(1, i + 1), lambda i: f'V{i}: E')}>( 40 | _ direction: SortDirection = .ascending, 41 | by ${args(range(1, i + 1), lambda i: f'keyPath{i}: KeyPath', ',\n _ ')} 42 | ) -> QueryBuilder { 43 | let sortDescriptors = [ 44 | % for k in range(1, i + 1): 45 | NSSortDescriptor(objectKeyPath: keyPath${k}, ascending: direction.isAscending), 46 | % end 47 | ] 48 | return order(by: sortDescriptors) 49 | } 50 | % end 51 | } 52 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/QueryBuilder+Select.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder+Select.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | import Foundation 10 | import PredicateQI 11 | 12 | public extension QueryBuilder { 13 | func distinct(_ returnsDistinctResults: Bool = true) -> QueryBuilder { 14 | var query = self 15 | query.returnsDistinctResults = returnsDistinctResults 16 | return query 17 | } 18 | 19 | func select(_ properties: [NSPropertyDescription]) -> QueryBuilder { 20 | select(properties.map(FetchedProperty.property)) 21 | } 22 | 23 | func select(_ properties: NSPropertyDescription...) -> QueryBuilder { 24 | select(properties) 25 | } 26 | 27 | func select(_ properties: [String]) -> QueryBuilder { 28 | select(properties.map(FetchedProperty.string)) 29 | } 30 | 31 | func select(_ properties: String...) -> QueryBuilder { 32 | select(properties) 33 | } 34 | 35 | func select(_ keyPath: KeyPath, name: String? = nil, type: NSAttributeDescription.AttributeType? = nil) -> QueryBuilder { 36 | select(NSExpressionDescription(objectKeyPath: keyPath, name: name, type: type)) 37 | } 38 | 39 | func select( 40 | _ keyPath1: KeyPath, 41 | _ keyPath2: KeyPath 42 | ) -> QueryBuilder { 43 | select(keyPath1) 44 | .select(keyPath2) 45 | } 46 | 47 | func select( 48 | _ keyPath1: KeyPath, 49 | _ keyPath2: KeyPath, 50 | _ keyPath3: KeyPath 51 | ) -> QueryBuilder { 52 | select(keyPath1) 53 | .select(keyPath2) 54 | .select(keyPath3) 55 | } 56 | 57 | func select( 58 | _ keyPath1: KeyPath, 59 | _ keyPath2: KeyPath, 60 | _ keyPath3: KeyPath, 61 | _ keyPath4: KeyPath 62 | ) -> QueryBuilder { 63 | select(keyPath1) 64 | .select(keyPath2) 65 | .select(keyPath3) 66 | .select(keyPath4) 67 | } 68 | 69 | func select( 70 | _ keyPath1: KeyPath, 71 | _ keyPath2: KeyPath, 72 | _ keyPath3: KeyPath, 73 | _ keyPath4: KeyPath, 74 | _ keyPath5: KeyPath 75 | ) -> QueryBuilder { 76 | select(keyPath1) 77 | .select(keyPath2) 78 | .select(keyPath3) 79 | .select(keyPath4) 80 | .select(keyPath5) 81 | } 82 | 83 | func select( 84 | _ keyPath1: KeyPath, 85 | _ keyPath2: KeyPath, 86 | _ keyPath3: KeyPath, 87 | _ keyPath4: KeyPath, 88 | _ keyPath5: KeyPath, 89 | _ keyPath6: KeyPath 90 | ) -> QueryBuilder { 91 | select(keyPath1) 92 | .select(keyPath2) 93 | .select(keyPath3) 94 | .select(keyPath4) 95 | .select(keyPath5) 96 | .select(keyPath6) 97 | } 98 | 99 | func select( 100 | _ keyPath1: KeyPath, 101 | _ keyPath2: KeyPath, 102 | _ keyPath3: KeyPath, 103 | _ keyPath4: KeyPath, 104 | _ keyPath5: KeyPath, 105 | _ keyPath6: KeyPath, 106 | _ keyPath7: KeyPath 107 | ) -> QueryBuilder { 108 | select(keyPath1) 109 | .select(keyPath2) 110 | .select(keyPath3) 111 | .select(keyPath4) 112 | .select(keyPath5) 113 | .select(keyPath6) 114 | .select(keyPath7) 115 | } 116 | 117 | private func select(_ properties: [FetchedProperty]) -> QueryBuilder { 118 | var query = self 119 | query.propertiesToFetch.append(contentsOf: properties) 120 | return query 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/QueryBuilder+Select.swift.gyb: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder+Select.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | %{ 8 | def args(items, fmt=lambda i: f'{i}', sep=', '): 9 | return sep.join(map(fmt, items)) 10 | }% 11 | 12 | import CoreData 13 | import Foundation 14 | import PredicateQI 15 | 16 | public extension QueryBuilder { 17 | func distinct(_ returnsDistinctResults: Bool = true) -> QueryBuilder { 18 | var query = self 19 | query.returnsDistinctResults = returnsDistinctResults 20 | return query 21 | } 22 | 23 | func select(_ properties: [NSPropertyDescription]) -> QueryBuilder { 24 | select(properties.map(FetchedProperty.property)) 25 | } 26 | 27 | func select(_ properties: NSPropertyDescription...) -> QueryBuilder { 28 | select(properties) 29 | } 30 | 31 | func select(_ properties: [String]) -> QueryBuilder { 32 | select(properties.map(FetchedProperty.string)) 33 | } 34 | 35 | func select(_ properties: String...) -> QueryBuilder { 36 | select(properties) 37 | } 38 | 39 | func select(_ keyPath: KeyPath, name: String? = nil, type: NSAttributeDescription.AttributeType? = nil) -> QueryBuilder { 40 | select(NSExpressionDescription(objectKeyPath: keyPath, name: name, type: type)) 41 | } 42 | % for i in range(2, 8): 43 | 44 | func select<${args(range(1, i + 1), lambda i: f'V{i}: E')}>( 45 | _ ${args(range(1, i + 1), lambda i: f'keyPath{i}: KeyPath', ',\n _ ')} 46 | ) -> QueryBuilder { 47 | select(keyPath1) 48 | % for k in range(2, i + 1): 49 | .select(keyPath${k}) 50 | % end 51 | } 52 | % end 53 | 54 | private func select(_ properties: [FetchedProperty]) -> QueryBuilder { 55 | var query = self 56 | query.propertiesToFetch.append(contentsOf: properties) 57 | return query 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/CoreDataQueryInterface/QueryBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilder.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Gregory Higley on 2022-10-21. 6 | // 7 | 8 | import CoreData 9 | import PredicateQI 10 | 11 | public struct QueryBuilder { 12 | public typealias O = Object 13 | public typealias E = Expression 14 | 15 | weak var managedObjectContext: NSManagedObjectContext? 16 | var fetchLimit: Int = 0 17 | var fetchOffset: Int = 0 18 | var predicates: [NSPredicate] = [] 19 | var sortDescriptors: [NSSortDescriptor] = [] 20 | var propertiesToFetch: [FetchedProperty] = [] 21 | var propertiesToGroupBy: [FetchedProperty] = [] 22 | var returnsDistinctResults = false 23 | 24 | init() {} 25 | 26 | init(copying query: QueryBuilder) { 27 | managedObjectContext = query.managedObjectContext 28 | fetchLimit = query.fetchLimit 29 | fetchOffset = query.fetchOffset 30 | predicates = query.predicates 31 | sortDescriptors = query.sortDescriptors 32 | propertiesToFetch = query.propertiesToFetch 33 | propertiesToGroupBy = query.propertiesToGroupBy 34 | returnsDistinctResults = query.returnsDistinctResults 35 | } 36 | 37 | public func context(_ moc: NSManagedObjectContext) -> QueryBuilder { 38 | var query = self 39 | query.managedObjectContext = moc 40 | return query 41 | } 42 | 43 | /** 44 | Resets the set of the receiver so that it can be filtered, 45 | ordered, etc. again. 46 | 47 | ```swift 48 | let query1 = Query(Person.self).filter { $0.name == "Bob" } 49 | let query2 = query1.re(.filter).filter { $0.name == "Fred" } 50 | ``` 51 | 52 | The `filter` function is cumulative. Each new application of it 53 | adds a new filter (implicitly using the AND conjunction). Calling 54 | `un(.filter)` erases all previous filters, allowing new ones to 55 | be added. 56 | */ 57 | public func un(_ states: [QueryBuilderState]) -> QueryBuilder { 58 | var query = self 59 | for state in states { 60 | switch state { 61 | case .filter: 62 | query.predicates = [] 63 | case .order: 64 | query.sortDescriptors = [] 65 | case .select: 66 | query.propertiesToFetch = [] 67 | case .group: 68 | query.propertiesToGroupBy = [] 69 | } 70 | } 71 | return query 72 | } 73 | 74 | public func un(_ states: QueryBuilderState...) -> QueryBuilder { 75 | un(states) 76 | } 77 | } 78 | 79 | /** 80 | This function is called the "Query Constructor". Use it to 81 | start a new query. 82 | 83 | ```swift 84 | Query(Person.self).filter { $0.lastName == "Smith" } 85 | ``` 86 | 87 | - Note: This function is deliberately capitalized to mimic a constructor. 88 | */ 89 | public func Query(_ entityType: M.Type) -> QueryBuilder { 90 | .init() 91 | } 92 | 93 | public enum QueryBuilderState: CaseIterable { 94 | case filter, order, select, group 95 | } 96 | 97 | enum FetchedProperty { 98 | case string(String) 99 | case property(NSPropertyDescription) 100 | 101 | var asAny: Any { 102 | switch self { 103 | case .string(let value): 104 | return value 105 | case .property(let value): 106 | return value 107 | } 108 | } 109 | } 110 | 111 | extension FetchedProperty: Equatable { 112 | public static func == (lhs: FetchedProperty, rhs: FetchedProperty) -> Bool { 113 | let equal: Bool 114 | switch (lhs, rhs) { 115 | case let (.string(value1), .string(value2)): 116 | equal = value1 == value2 117 | case let (.property(value1), .property(value2)): 118 | equal = value1 == value2 119 | default: 120 | equal = false 121 | } 122 | return equal 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/Developer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Developer.swift 3 | // CoreDataQueryInterfaceTests 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | 10 | @objc(Developer) 11 | class Developer: NSManagedObject { 12 | @NSManaged var firstName: String 13 | @NSManaged var lastName: String 14 | @NSManaged var languages: Set 15 | } 16 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/Developers.xcdatamodeld/Model.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/Language.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Language.swift 3 | // CoreDataQueryInterfaceTests 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | 10 | @objc(Language) 11 | class Language: NSManagedObject { 12 | @NSManaged var name: String 13 | @NSManaged var developers: Set 14 | } 15 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/QueryBuilderFetchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilderFetchTests.swift 3 | // CoreDataQueryInterfaceTests 4 | // 5 | // Created by Gregory Higley on 2022-10-24. 6 | // 7 | 8 | import CoreDataQueryInterface 9 | import PredicateQI 10 | import XCTest 11 | 12 | final class QueryBuilderFetchTests: XCTestCase { 13 | func testFetchIDs() throws { 14 | let moc = Store.container.viewContext 15 | let ids = try Query(Language.self).fetchIDs(moc) 16 | XCTAssertNotEqual(ids.count, 0) 17 | } 18 | 19 | func testFetchObjects() throws { 20 | let moc = Store.container.viewContext 21 | let objects = try moc.query(Language.self).fetchObjects() 22 | XCTAssertNotEqual(objects.count, 0) 23 | } 24 | 25 | func testFetchDictionaries() throws { 26 | let moc = Store.container.viewContext 27 | let dictionaries = try Query(Language.self).context(moc).fetchDictionaries() 28 | XCTAssertNotEqual(dictionaries.count, 0) 29 | } 30 | 31 | func testFetchOffsetAndLimit() throws { 32 | let moc = Store.container.viewContext 33 | let developer = try Query(Developer.self).offset(1).limit(1).order(.descending, by: \.lastName).fetch(moc).first 34 | XCTAssertEqual(developer?.lastName, "Disraeli") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/QueryBuilderFilterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilderFilterTests.swift 3 | // CoreDataQueryInterface 4 | // 5 | // Created by Greg Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | import CoreDataQueryInterface 10 | import PredicateQI 11 | import XCTest 12 | 13 | final class QueryBuilderFilterTests: XCTestCase { 14 | func testFilterByRelatedEntity() throws { 15 | let moc = Store.container.viewContext 16 | let count = try moc.query(Language.self).filter { $0.developers.pqiCount == 0 }.count() 17 | XCTAssertEqual(count, 2) 18 | } 19 | 20 | func testFilterBySubquery() throws { 21 | let moc = Store.container.viewContext 22 | let filter: (Object) -> PredicateBuilder = { 23 | // SUBQUERY(developers, $v, ANY $v.lastName BEGINSWITH[c] "d").@count > 0 24 | $0.developers.where { any(ci($0.lastName <~% "d")) } 25 | } 26 | let count = try moc.query(Language.self).filter(filter).count() 27 | XCTAssertEqual(count, 2) 28 | } 29 | 30 | func testFilterWithArgs() throws { 31 | let moc = Store.container.viewContext 32 | let count = try moc.query(Language.self).filter("%K BEGINSWITH %@", "name", "R").count() 33 | XCTAssertEqual(count, 2) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/QueryBuilderGroupTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilderGroupTests.swift 3 | // CoreDataQueryInterfaceTests 4 | // 5 | // Created by Gregory Higley on 2022-10-24. 6 | // 7 | 8 | import CoreDataQueryInterface 9 | import PredicateQI 10 | import XCTest 11 | 12 | final class QueryBuilderGroupTests: XCTestCase { 13 | func testGroupByName() throws { 14 | let moc = Store.container.viewContext 15 | let query = Query(Developer.self) 16 | .group(by: \.lastName, \.firstName) 17 | .select(\.firstName, \.lastName, \.languages.name.pqiCount.pqiFloat) 18 | .filter { ci($0.lastName == "higley")} 19 | let result = try query.fetchDictionaries(moc) 20 | print(result[0].keys) 21 | let count = result[0]["@count"] as! Int64 22 | XCTAssertEqual(count, 3) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/QueryBuilderOrderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilderOrderTests.swift 3 | // CoreDataQueryInterfaceTests 4 | // 5 | // Created by Greg Higley on 2022-10-22. 6 | // 7 | 8 | import CoreDataQueryInterface 9 | import PredicateQI 10 | import XCTest 11 | 12 | final class QueryBuilderOrderTests: XCTestCase { 13 | func testOrderByName() throws { 14 | let moc = Store.container.viewContext 15 | let languages = try moc.query(Language.self).order(by: \.name).fetch() 16 | XCTAssertFalse(languages.isEmpty) 17 | XCTAssertEqual(languages.first?.name, "Haskell") 18 | } 19 | 20 | func testOrderDescendingByName() throws { 21 | let moc = Store.container.viewContext 22 | let languages = try moc.query(Language.self).order(.descending, by: \.name).fetch() 23 | XCTAssertFalse(languages.isEmpty) 24 | XCTAssertEqual(languages.first?.name, "Visual Basic") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/QueryBuilderSelectTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilderSelectTests.swift 3 | // CoreDataQueryInterfaceTests 4 | // 5 | // Created by Greg Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | import CoreDataQueryInterface 10 | import PredicateQI 11 | import XCTest 12 | 13 | final class QueryBuilderSelectTests: XCTestCase { 14 | func testSelectWithKeyPaths() throws { 15 | let moc = Store.container.viewContext 16 | let developers = try Query(Developer.self) 17 | .context(moc) 18 | .order(.descending, by: \.lastName, \.firstName) 19 | .select(\.firstName, \.lastName) 20 | .fetchDictionaries() 21 | XCTAssertFalse(developers.isEmpty) 22 | XCTAssertEqual(developers[0]["lastName"] as? String, "Higley") 23 | } 24 | 25 | func testSelectWithPropertyDescriptions() throws { 26 | let moc = Store.container.viewContext 27 | let firstName = NSExpressionDescription() 28 | firstName.name = "firstName" 29 | let developers = try moc.query(Developer.self) 30 | .order(.descending, by: \.lastName) 31 | .filter { $0.languages.where { any($0.name == "Rust") } } 32 | .select(firstName) 33 | .fetchDictionaries() 34 | XCTAssertFalse(developers.isEmpty) 35 | } 36 | 37 | func testDistinct() throws { 38 | let moc = Store.container.viewContext 39 | let developers = try Query(Developer.self) 40 | .distinct() 41 | .select(\.lastName) 42 | .fetchDictionaries(moc) 43 | XCTAssertFalse(developers.isEmpty) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/QueryBuilderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryBuilderTests.swift 3 | // CoreDataQueryInterfaceTests 4 | // 5 | // Created by Gregory Higley on 2022-10-24. 6 | // 7 | 8 | import PredicateQI 9 | import XCTest 10 | 11 | @testable import CoreDataQueryInterface 12 | 13 | final class QueryBuilderTests: XCTestCase { 14 | func testReset() { 15 | let q1 = Query(Language.self).filter { $0.name |~> "us" }.select(\.name).order(by: \.name) 16 | let q2 = q1.un(.filter, .order, .select) 17 | 18 | // Predicates 19 | XCTAssertNotEqual(q1.predicates, q2.predicates) 20 | XCTAssertEqual(q2.predicates.count, 0) 21 | 22 | // Order By 23 | XCTAssertNotEqual(q1.sortDescriptors, q2.sortDescriptors) 24 | XCTAssertEqual(q2.sortDescriptors.count, 0) 25 | 26 | // Select 27 | XCTAssertNotEqual(q1.propertiesToFetch, q2.propertiesToFetch) 28 | XCTAssertEqual(q2.propertiesToFetch.count, 0) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/CoreDataQueryInterfaceTests/Store.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Store.swift 3 | // CoreDataQueryInterfaceTests 4 | // 5 | // Created by Gregory Higley on 2022-10-22. 6 | // 7 | 8 | import CoreData 9 | import CoreDataQueryInterface 10 | import PredicateQI 11 | 12 | enum Store { 13 | private static func initPersistentContainer() -> NSPersistentContainer { 14 | PredicateQIConfiguration.logPredicatesToConsole = true 15 | 16 | let mom = NSManagedObjectModel.mergedModel(from: [Bundle.module])! 17 | let container = NSPersistentContainer(name: "developers", managedObjectModel: mom) 18 | let store = NSPersistentStoreDescription(url: URL(fileURLWithPath: "/dev/null")) 19 | container.persistentStoreDescriptions = [store] 20 | container.loadPersistentStores { _, error in 21 | if let error = error { 22 | assertionFailure("\(error)") 23 | } 24 | } 25 | loadData(into: container) 26 | return container 27 | } 28 | 29 | private(set) static var container = Self.initPersistentContainer() 30 | 31 | private static func loadData(into container: NSPersistentContainer) { 32 | let moc = container.viewContext 33 | let languages = ["Swift", "Haskell", "Visual Basic", "Rust", "Ruby", "Kotlin", "Python"] 34 | for name in languages { 35 | let language = Language(context: moc) 36 | language.name = name 37 | } 38 | try! moc.save() 39 | 40 | let developers: [[String: Any]] = [ 41 | ["fn": "Iulius", "ln": "Caesar", "ls": ["Ruby", "Swift"]], 42 | ["fn": "Benjamin", "ln": "Disraeli", "ls": ["Swift", "Kotlin"]], 43 | ["fn": "Gregory", "ln": "Higley", "ls": ["Swift", "Rust", "Haskell"]] 44 | ] 45 | 46 | for info in developers { 47 | let firstName = info["fn"] as! String 48 | let lastName = info["ln"] as! String 49 | let languageNames = info["ls"] as! [String] 50 | var languages: Set = [] 51 | for name in languageNames { 52 | let language = try! moc.query(Language.self).filter { $0.name == name }.fetchFirst()! 53 | languages.insert(language) 54 | } 55 | let developer = Developer(context: moc) 56 | developer.firstName = firstName 57 | developer.lastName = lastName 58 | developer.languages = languages 59 | } 60 | try! moc.save() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Vendor/get-gyb: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | rm gyb gyb.py 4 | curl -O 'https:/raw.githubusercontent.com/apple/swift/main/utils/gyb' 5 | curl -O 'https:/raw.githubusercontent.com/apple/swift/main/utils/gyb.py' 6 | chmod +x gyb -------------------------------------------------------------------------------- /Vendor/gyb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import gyb 3 | gyb.main() 4 | -------------------------------------------------------------------------------- /Vendor/gyb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # GYB: Generate Your Boilerplate (improved names welcome; at least 3 | # this one's short). See -h output for instructions 4 | 5 | import io 6 | import os 7 | import re 8 | import sys 9 | import textwrap 10 | import tokenize 11 | from bisect import bisect 12 | from io import StringIO 13 | 14 | 15 | def get_line_starts(s): 16 | """Return a list containing the start index of each line in s. 17 | 18 | The list also contains a sentinel index for the end of the string, 19 | so there will be one more element in the list than there are lines 20 | in the string 21 | """ 22 | starts = [0] 23 | 24 | for line in s.split('\n'): 25 | starts.append(starts[-1] + len(line) + 1) 26 | 27 | starts[-1] -= 1 28 | return starts 29 | 30 | 31 | def strip_trailing_nl(s): 32 | """If s ends with a newline, drop it; else return s intact""" 33 | return s[:-1] if s.endswith('\n') else s 34 | 35 | 36 | def split_lines(s): 37 | """Split s into a list of lines, each of which has a trailing newline 38 | 39 | If the lines are later concatenated, the result is s, possibly 40 | with a single appended newline. 41 | """ 42 | return [line + '\n' for line in s.split('\n')] 43 | 44 | 45 | # text on a line up to the first '$$', '${', or '%%' 46 | literalText = r'(?: [^$\n%] | \$(?![${]) | %(?!%) )*' 47 | 48 | # The part of an '%end' line that follows the '%' sign 49 | linesClose = r'[\ \t]* end [\ \t]* (?: \# .* )? $' 50 | 51 | # Note: Where "# Absorb" appears below, the regexp attempts to eat up 52 | # through the end of ${...} and %{...}% constructs. In reality we 53 | # handle this with the Python tokenizer, which avoids mis-detections 54 | # due to nesting, comments and strings. This extra absorption in the 55 | # regexp facilitates testing the regexp on its own, by preventing the 56 | # interior of some of these constructs from being treated as literal 57 | # text. 58 | tokenize_re = re.compile( 59 | r''' 60 | # %-lines and %{...}-blocks 61 | # \n? # absorb one preceding newline 62 | ^ 63 | (?: 64 | (?P 65 | (?P<_indent> [\ \t]* % (?! [{%] ) [\ \t]* ) (?! [\ \t] | ''' + 66 | linesClose + r''' ) .* 67 | ( \n (?P=_indent) (?! ''' + linesClose + r''' ) .* ) * 68 | ) 69 | | (?P [\ \t]* % [ \t]* ''' + linesClose + r''' ) 70 | | [\ \t]* (?P %\{ ) 71 | (?: [^}]| \} (?!%) )* \}% # Absorb 72 | ) 73 | \n? # absorb one trailing newline 74 | 75 | # Substitutions 76 | | (?P \$\{ ) 77 | [^}]* \} # Absorb 78 | 79 | # %% and $$ are literal % and $ respectively 80 | | (?P[$%]) (?P=symbol) 81 | 82 | # Literal text 83 | | (?P ''' + literalText + r''' 84 | (?: 85 | # newline that doesn't precede space+% 86 | (?: \n (?! [\ \t]* %[^%] ) ) 87 | ''' + literalText + r''' 88 | )* 89 | \n? 90 | ) 91 | ''', re.VERBOSE | re.MULTILINE) 92 | 93 | gyb_block_close = re.compile(r'\}%[ \t]*\n?') 94 | 95 | 96 | def token_pos_to_index(token_pos, start, line_starts): 97 | """Translate a tokenize (line, column) pair into an absolute 98 | position in source text given the position where we started 99 | tokenizing and a list that maps lines onto their starting 100 | character indexes. 101 | """ 102 | relative_token_line_plus1, token_col = token_pos 103 | 104 | # line number where we started tokenizing 105 | start_line_num = bisect(line_starts, start) - 1 106 | 107 | # line number of the token in the whole text 108 | abs_token_line = relative_token_line_plus1 - 1 + start_line_num 109 | 110 | # if found in the first line, adjust the end column to account 111 | # for the extra text 112 | if relative_token_line_plus1 == 1: 113 | token_col += start - line_starts[start_line_num] 114 | 115 | # Sometimes tokenizer errors report a line beyond the last one 116 | if abs_token_line >= len(line_starts): 117 | return line_starts[-1] 118 | 119 | return line_starts[abs_token_line] + token_col 120 | 121 | 122 | def tokenize_python_to_unmatched_close_curly(source_text, start, line_starts): 123 | """Apply Python's tokenize to source_text starting at index start 124 | while matching open and close curly braces. When an unmatched 125 | close curly brace is found, return its index. If not found, 126 | return len(source_text). If there's a tokenization error, return 127 | the position of the error. 128 | """ 129 | stream = StringIO(source_text) 130 | stream.seek(start) 131 | nesting = 0 132 | 133 | try: 134 | for kind, text, token_start, token_end, line_text \ 135 | in tokenize.generate_tokens(stream.readline): 136 | 137 | if text == '{': 138 | nesting += 1 139 | elif text == '}': 140 | nesting -= 1 141 | if nesting < 0: 142 | return token_pos_to_index(token_start, start, line_starts) 143 | 144 | except tokenize.TokenError as error: 145 | (message, error_pos) = error.args 146 | return token_pos_to_index(error_pos, start, line_starts) 147 | 148 | return len(source_text) 149 | 150 | 151 | def tokenize_template(template_text): 152 | r"""Given the text of a template, returns an iterator over 153 | (tokenType, token, match) tuples. 154 | 155 | **Note**: this is template syntax tokenization, not Python 156 | tokenization. 157 | 158 | When a non-literal token is matched, a client may call 159 | iter.send(pos) on the iterator to reset the position in 160 | template_text at which scanning will resume. 161 | 162 | This function provides a base level of tokenization which is 163 | then refined by ParseContext.token_generator. 164 | 165 | >>> from pprint import * 166 | >>> pprint(list((kind, text) for kind, text, _ in tokenize_template( 167 | ... '%for x in range(10):\n% print x\n%end\njuicebox'))) 168 | [('gybLines', '%for x in range(10):\n% print x'), 169 | ('gybLinesClose', '%end'), 170 | ('literal', 'juicebox')] 171 | 172 | >>> pprint(list((kind, text) for kind, text, _ in tokenize_template( 173 | ... '''Nothing 174 | ... % if x: 175 | ... % for i in range(3): 176 | ... ${i} 177 | ... % end 178 | ... % else: 179 | ... THIS SHOULD NOT APPEAR IN THE OUTPUT 180 | ... '''))) 181 | [('literal', 'Nothing\n'), 182 | ('gybLines', '% if x:\n% for i in range(3):'), 183 | ('substitutionOpen', '${'), 184 | ('literal', '\n'), 185 | ('gybLinesClose', '% end'), 186 | ('gybLines', '% else:'), 187 | ('literal', 'THIS SHOULD NOT APPEAR IN THE OUTPUT\n')] 188 | 189 | >>> for kind, text, _ in tokenize_template(''' 190 | ... This is $some$ literal stuff containing a ${substitution} 191 | ... followed by a %{...} block: 192 | ... %{ 193 | ... # Python code 194 | ... }% 195 | ... and here $${are} some %-lines: 196 | ... % x = 1 197 | ... % y = 2 198 | ... % if z == 3: 199 | ... % print '${hello}' 200 | ... % end 201 | ... % for x in zz: 202 | ... % print x 203 | ... % # different indentation 204 | ... % twice 205 | ... and some lines that literally start with a %% token 206 | ... %% first line 207 | ... %% second line 208 | ... '''): 209 | ... print((kind, text.strip().split('\n',1)[0])) 210 | ('literal', 'This is $some$ literal stuff containing a') 211 | ('substitutionOpen', '${') 212 | ('literal', 'followed by a %{...} block:') 213 | ('gybBlockOpen', '%{') 214 | ('literal', 'and here ${are} some %-lines:') 215 | ('gybLines', '% x = 1') 216 | ('gybLinesClose', '% end') 217 | ('gybLines', '% for x in zz:') 218 | ('gybLines', '% # different indentation') 219 | ('gybLines', '% twice') 220 | ('literal', 'and some lines that literally start with a % token') 221 | """ 222 | pos = 0 223 | end = len(template_text) 224 | 225 | saved_literal = [] 226 | literal_first_match = None 227 | 228 | while pos < end: 229 | m = tokenize_re.match(template_text, pos, end) 230 | 231 | # pull out the one matched key (ignoring internal patterns starting 232 | # with _) 233 | ((kind, text), ) = ( 234 | (kind, text) for (kind, text) in m.groupdict().items() 235 | if text is not None and kind[0] != '_') 236 | 237 | if kind in ('literal', 'symbol'): 238 | if len(saved_literal) == 0: 239 | literal_first_match = m 240 | # literals and symbols get batched together 241 | saved_literal.append(text) 242 | pos = None 243 | else: 244 | # found a non-literal. First yield any literal we've accumulated 245 | if saved_literal != []: 246 | yield 'literal', ''.join(saved_literal), literal_first_match 247 | saved_literal = [] 248 | 249 | # Then yield the thing we found. If we get a reply, it's 250 | # the place to resume tokenizing 251 | pos = yield kind, text, m 252 | 253 | # If we were not sent a new position by our client, resume 254 | # tokenizing at the end of this match. 255 | if pos is None: 256 | pos = m.end(0) 257 | else: 258 | # Client is not yet ready to process next token 259 | yield 260 | 261 | if saved_literal != []: 262 | yield 'literal', ''.join(saved_literal), literal_first_match 263 | 264 | 265 | def split_gyb_lines(source_lines): 266 | r"""Return a list of lines at which to split the incoming source 267 | 268 | These positions represent the beginnings of python line groups that 269 | will require a matching %end construct if they are to be closed. 270 | 271 | >>> src = split_lines('''\ 272 | ... if x: 273 | ... print x 274 | ... if y: # trailing comment 275 | ... print z 276 | ... if z: # another comment\ 277 | ... ''') 278 | >>> s = split_gyb_lines(src) 279 | >>> len(s) 280 | 2 281 | >>> src[s[0]] 282 | ' print z\n' 283 | >>> s[1] - len(src) 284 | 0 285 | 286 | >>> src = split_lines('''\ 287 | ... if x: 288 | ... if y: print 1 289 | ... if z: 290 | ... print 2 291 | ... pass\ 292 | ... ''') 293 | >>> s = split_gyb_lines(src) 294 | >>> len(s) 295 | 1 296 | >>> src[s[0]] 297 | ' if y: print 1\n' 298 | 299 | >>> src = split_lines('''\ 300 | ... if x: 301 | ... if y: 302 | ... print 1 303 | ... print 2 304 | ... ''') 305 | >>> s = split_gyb_lines(src) 306 | >>> len(s) 307 | 2 308 | >>> src[s[0]] 309 | ' if y:\n' 310 | >>> src[s[1]] 311 | ' print 1\n' 312 | """ 313 | last_token_text, last_token_kind = None, None 314 | unmatched_indents = [] 315 | 316 | dedents = 0 317 | try: 318 | for token_kind, token_text, token_start, \ 319 | (token_end_line, token_end_col), line_text \ 320 | in tokenize.generate_tokens(lambda i=iter(source_lines): 321 | next(i)): 322 | 323 | if token_kind in (tokenize.COMMENT, tokenize.ENDMARKER): 324 | continue 325 | 326 | if token_text == '\n' and last_token_text == ':': 327 | unmatched_indents.append(token_end_line) 328 | 329 | # The tokenizer appends dedents at EOF; don't consider 330 | # those as matching indentations. Instead just save them 331 | # up... 332 | if last_token_kind == tokenize.DEDENT: 333 | dedents += 1 334 | # And count them later, when we see something real. 335 | if token_kind != tokenize.DEDENT and dedents > 0: 336 | unmatched_indents = unmatched_indents[:-dedents] 337 | dedents = 0 338 | 339 | last_token_text, last_token_kind = token_text, token_kind 340 | 341 | except tokenize.TokenError: 342 | # Let the later compile() call report the error 343 | return [] 344 | 345 | if last_token_text == ':': 346 | unmatched_indents.append(len(source_lines)) 347 | 348 | return unmatched_indents 349 | 350 | 351 | def code_starts_with_dedent_keyword(source_lines): 352 | r"""Return True iff the incoming Python source_lines begin with "else", 353 | "elif", "except", or "finally". 354 | 355 | Initial comments and whitespace are ignored. 356 | 357 | >>> code_starts_with_dedent_keyword(split_lines('if x in y: pass')) 358 | False 359 | >>> code_starts_with_dedent_keyword(split_lines('except ifSomethingElse:')) 360 | True 361 | >>> code_starts_with_dedent_keyword( 362 | ... split_lines('\n# comment\nelse: # yes')) 363 | True 364 | """ 365 | token_text = None 366 | for token_kind, token_text, _, _, _ \ 367 | in tokenize.generate_tokens(lambda i=iter(source_lines): next(i)): 368 | 369 | if token_kind != tokenize.COMMENT and token_text.strip() != '': 370 | break 371 | 372 | return token_text in ('else', 'elif', 'except', 'finally') 373 | 374 | 375 | class ParseContext(object): 376 | 377 | """State carried through a parse of a template""" 378 | 379 | filename = '' 380 | template = '' 381 | line_starts = [] 382 | code_start_line = -1 383 | code_text = None 384 | tokens = None # The rest of the tokens 385 | close_lines = False 386 | 387 | def __init__(self, filename, template=None): 388 | self.filename = os.path.abspath(filename) 389 | if sys.platform == 'win32': 390 | self.filename = '/'.join(self.filename.split(os.sep)) 391 | if template is None: 392 | with io.open(os.path.normpath(filename), encoding='utf-8') as f: 393 | self.template = f.read() 394 | else: 395 | self.template = template 396 | self.line_starts = get_line_starts(self.template) 397 | self.tokens = self.token_generator(tokenize_template(self.template)) 398 | self.next_token() 399 | 400 | def pos_to_line(self, pos): 401 | return bisect(self.line_starts, pos) - 1 402 | 403 | def token_generator(self, base_tokens): 404 | r"""Given an iterator over (kind, text, match) triples (see 405 | tokenize_template above), return a refined iterator over 406 | token_kinds. 407 | 408 | Among other adjustments to the elements found by base_tokens, 409 | this refined iterator tokenizes python code embedded in 410 | template text to help determine its true extent. The 411 | expression "base_tokens.send(pos)" is used to reset the index at 412 | which base_tokens resumes scanning the underlying text. 413 | 414 | >>> ctx = ParseContext('dummy', ''' 415 | ... %for x in y: 416 | ... % print x 417 | ... % end 418 | ... literally 419 | ... ''') 420 | >>> while ctx.token_kind: 421 | ... print((ctx.token_kind, ctx.code_text or ctx.token_text)) 422 | ... ignored = ctx.next_token() 423 | ('literal', '\n') 424 | ('gybLinesOpen', 'for x in y:\n') 425 | ('gybLines', ' print x\n') 426 | ('gybLinesClose', '% end') 427 | ('literal', 'literally\n') 428 | 429 | >>> ctx = ParseContext('dummy', 430 | ... '''Nothing 431 | ... % if x: 432 | ... % for i in range(3): 433 | ... ${i} 434 | ... % end 435 | ... % else: 436 | ... THIS SHOULD NOT APPEAR IN THE OUTPUT 437 | ... ''') 438 | >>> while ctx.token_kind: 439 | ... print((ctx.token_kind, ctx.code_text or ctx.token_text)) 440 | ... ignored = ctx.next_token() 441 | ('literal', 'Nothing\n') 442 | ('gybLinesOpen', 'if x:\n') 443 | ('gybLinesOpen', ' for i in range(3):\n') 444 | ('substitutionOpen', 'i') 445 | ('literal', '\n') 446 | ('gybLinesClose', '% end') 447 | ('gybLinesOpen', 'else:\n') 448 | ('literal', 'THIS SHOULD NOT APPEAR IN THE OUTPUT\n') 449 | 450 | >>> ctx = ParseContext('dummy', 451 | ... '''% for x in [1, 2, 3]: 452 | ... % if x == 1: 453 | ... literal1 454 | ... % elif x > 1: # add output line here to fix bug 455 | ... % if x == 2: 456 | ... literal2 457 | ... % end 458 | ... % end 459 | ... % end 460 | ... ''') 461 | >>> while ctx.token_kind: 462 | ... print((ctx.token_kind, ctx.code_text or ctx.token_text)) 463 | ... ignored = ctx.next_token() 464 | ('gybLinesOpen', 'for x in [1, 2, 3]:\n') 465 | ('gybLinesOpen', ' if x == 1:\n') 466 | ('literal', 'literal1\n') 467 | ('gybLinesOpen', 'elif x > 1: # add output line here to fix bug\n') 468 | ('gybLinesOpen', ' if x == 2:\n') 469 | ('literal', 'literal2\n') 470 | ('gybLinesClose', '% end') 471 | ('gybLinesClose', '% end') 472 | ('gybLinesClose', '% end') 473 | """ 474 | for self.token_kind, self.token_text, self.token_match in base_tokens: 475 | kind = self.token_kind 476 | self.code_text = None 477 | 478 | # Do we need to close the current lines? 479 | self.close_lines = kind == 'gybLinesClose' 480 | 481 | # %{...}% and ${...} constructs 482 | if kind.endswith('Open'): 483 | 484 | # Tokenize text that follows as Python up to an unmatched '}' 485 | code_start = self.token_match.end(kind) 486 | self.code_start_line = self.pos_to_line(code_start) 487 | 488 | close_pos = tokenize_python_to_unmatched_close_curly( 489 | self.template, code_start, self.line_starts) 490 | self.code_text = self.template[code_start:close_pos] 491 | yield kind 492 | 493 | if (kind == 'gybBlockOpen'): 494 | # Absorb any '}% \n' 495 | m2 = gyb_block_close.match(self.template, close_pos) 496 | if not m2: 497 | raise ValueError("Invalid block closure") 498 | next_pos = m2.end(0) 499 | else: 500 | assert kind == 'substitutionOpen' 501 | # skip past the closing '}' 502 | next_pos = close_pos + 1 503 | 504 | # Resume tokenizing after the end of the code. 505 | base_tokens.send(next_pos) 506 | 507 | elif kind == 'gybLines': 508 | 509 | self.code_start_line = self.pos_to_line( 510 | self.token_match.start('gybLines')) 511 | indentation = self.token_match.group('_indent') 512 | 513 | # Strip off the leading indentation and %-sign 514 | source_lines = re.split( 515 | '^' + re.escape(indentation), 516 | self.token_match.group('gybLines') + '\n', 517 | flags=re.MULTILINE)[1:] 518 | 519 | if code_starts_with_dedent_keyword(source_lines): 520 | self.close_lines = True 521 | 522 | last_split = 0 523 | for line in split_gyb_lines(source_lines): 524 | self.token_kind = 'gybLinesOpen' 525 | self.code_text = ''.join(source_lines[last_split:line]) 526 | yield self.token_kind 527 | last_split = line 528 | self.code_start_line += line - last_split 529 | self.close_lines = False 530 | 531 | self.code_text = ''.join(source_lines[last_split:]) 532 | if self.code_text: 533 | self.token_kind = 'gybLines' 534 | yield self.token_kind 535 | else: 536 | yield self.token_kind 537 | 538 | def next_token(self): 539 | """Move to the next token""" 540 | for kind in self.tokens: 541 | return self.token_kind 542 | 543 | self.token_kind = None 544 | 545 | 546 | _default_line_directive = \ 547 | '// ###sourceLocation(file: "%(file)s", line: %(line)d)' 548 | 549 | 550 | class ExecutionContext(object): 551 | 552 | """State we pass around during execution of a template""" 553 | 554 | def __init__(self, line_directive=_default_line_directive, 555 | **local_bindings): 556 | self.local_bindings = local_bindings 557 | self.line_directive = line_directive 558 | self.local_bindings['__context__'] = self 559 | self.result_text = [] 560 | self.last_file_line = None 561 | 562 | def append_text(self, text, file, line): 563 | # see if we need to inject a line marker 564 | if self.line_directive: 565 | if (file, line) != self.last_file_line: 566 | # We can only insert the line directive at a line break 567 | if len(self.result_text) == 0 \ 568 | or self.result_text[-1].endswith('\n'): 569 | substitutions = {'file': file, 'line': line + 1} 570 | format_str = self.line_directive + '\n' 571 | self.result_text.append(format_str % substitutions) 572 | # But if the new text contains any line breaks, we can create 573 | # one 574 | elif '\n' in text: 575 | i = text.find('\n') 576 | self.result_text.append(text[:i + 1]) 577 | # and try again 578 | self.append_text(text[i + 1:], file, line + 1) 579 | return 580 | 581 | self.result_text.append(text) 582 | self.last_file_line = (file, line + text.count('\n')) 583 | 584 | 585 | class ASTNode(object): 586 | 587 | """Abstract base class for template AST nodes""" 588 | 589 | def __init__(self): 590 | raise NotImplementedError("ASTNode.__init__ is not implemented.") 591 | 592 | def execute(self, context): 593 | raise NotImplementedError("ASTNode.execute is not implemented.") 594 | 595 | def __str__(self, indent=''): 596 | raise NotImplementedError("ASTNode.__str__ is not implemented.") 597 | 598 | def format_children(self, indent): 599 | if not self.children: 600 | return ' []' 601 | 602 | return '\n'.join( 603 | ['', indent + '['] + 604 | [x.__str__(indent + 4 * ' ') for x in self.children] + 605 | [indent + ']']) 606 | 607 | 608 | class Block(ASTNode): 609 | 610 | """A sequence of other AST nodes, to be executed in order""" 611 | 612 | children = [] 613 | 614 | def __init__(self, context): 615 | self.children = [] 616 | 617 | while context.token_kind and not context.close_lines: 618 | if context.token_kind == 'literal': 619 | node = Literal 620 | else: 621 | node = Code 622 | self.children.append(node(context)) 623 | 624 | def execute(self, context): 625 | for x in self.children: 626 | x.execute(context) 627 | 628 | def __str__(self, indent=''): 629 | return indent + 'Block:' + self.format_children(indent) 630 | 631 | 632 | class Literal(ASTNode): 633 | 634 | """An AST node that generates literal text""" 635 | 636 | def __init__(self, context): 637 | self.text = context.token_text 638 | start_position = context.token_match.start(context.token_kind) 639 | self.start_line_number = context.pos_to_line(start_position) 640 | self.filename = context.filename 641 | context.next_token() 642 | 643 | def execute(self, context): 644 | context.append_text(self.text, self.filename, self.start_line_number) 645 | 646 | def __str__(self, indent=''): 647 | return '\n'.join( 648 | [indent + x for x in ['Literal:'] + 649 | strip_trailing_nl(self.text).split('\n')]) 650 | 651 | 652 | class Code(ASTNode): 653 | 654 | """An AST node that is evaluated as Python""" 655 | 656 | code = None 657 | children = () 658 | kind = None 659 | 660 | def __init__(self, context): 661 | 662 | source = '' 663 | source_line_count = 0 664 | 665 | def accumulate_code(): 666 | s = source + (context.code_start_line - source_line_count) * '\n' \ 667 | + textwrap.dedent(context.code_text) 668 | line_count = context.code_start_line + \ 669 | context.code_text.count('\n') 670 | context.next_token() 671 | return s, line_count 672 | 673 | eval_exec = 'exec' 674 | if context.token_kind.startswith('substitution'): 675 | eval_exec = 'eval' 676 | source, source_line_count = accumulate_code() 677 | source = '(' + source.strip() + ')' 678 | 679 | else: 680 | while context.token_kind == 'gybLinesOpen': 681 | source, source_line_count = accumulate_code() 682 | source += ' __children__[%d].execute(__context__)\n' % len( 683 | self.children) 684 | source_line_count += 1 685 | 686 | self.children += (Block(context),) 687 | 688 | if context.token_kind == 'gybLinesClose': 689 | context.next_token() 690 | 691 | if context.token_kind == 'gybLines': 692 | source, source_line_count = accumulate_code() 693 | 694 | # Only handle a substitution as part of this code block if 695 | # we don't already have some %-lines. 696 | elif context.token_kind == 'gybBlockOpen': 697 | 698 | # Opening ${...} and %{...}% constructs 699 | source, source_line_count = accumulate_code() 700 | 701 | self.filename = context.filename 702 | self.start_line_number = context.code_start_line 703 | self.code = compile(source, context.filename, eval_exec) 704 | self.source = source 705 | 706 | def execute(self, context): 707 | # Save __children__ from the local bindings 708 | save_children = context.local_bindings.get('__children__') 709 | # Execute the code with our __children__ in scope 710 | context.local_bindings['__children__'] = self.children 711 | context.local_bindings['__file__'] = self.filename 712 | result = eval(self.code, context.local_bindings) 713 | 714 | if context.local_bindings['__children__'] is not self.children: 715 | raise ValueError("The code is not allowed to mutate __children__") 716 | # Restore the bindings 717 | context.local_bindings['__children__'] = save_children 718 | 719 | # If we got a result, the code was an expression, so append 720 | # its value 721 | if result is not None \ 722 | or (isinstance(result, str) and result != ''): 723 | from numbers import Number, Integral 724 | result_string = None 725 | if isinstance(result, Number) and not isinstance(result, Integral): 726 | result_string = repr(result) 727 | elif isinstance(result, Integral) or isinstance(result, list): 728 | result_string = str(result) 729 | else: 730 | result_string = result 731 | context.append_text( 732 | result_string, self.filename, self.start_line_number) 733 | 734 | def __str__(self, indent=''): 735 | source_lines = re.sub(r'^\n', '', strip_trailing_nl( 736 | self.source), flags=re.MULTILINE).split('\n') 737 | if len(source_lines) == 1: 738 | s = indent + 'Code: {' + source_lines[0] + '}' 739 | else: 740 | s = indent + 'Code:\n' + indent + '{\n' + '\n'.join( 741 | indent + 4 * ' ' + line for line in source_lines 742 | ) + '\n' + indent + '}' 743 | return s + self.format_children(indent) 744 | 745 | 746 | def expand(filename, line_directive=_default_line_directive, **local_bindings): 747 | r"""Return the contents of the given template file, executed with the given 748 | local bindings. 749 | 750 | >>> from tempfile import NamedTemporaryFile 751 | >>> # On Windows, the name of a NamedTemporaryFile cannot be used to open 752 | >>> # the file for a second time if delete=True. Therefore, we have to 753 | >>> # manually handle closing and deleting this file to allow us to open 754 | >>> # the file by its name across all platforms. 755 | >>> f = NamedTemporaryFile(delete=False) 756 | >>> _ = f.write( 757 | ... br'''--- 758 | ... % for i in range(int(x)): 759 | ... a pox on ${i} for epoxy 760 | ... % end 761 | ... ${120 + 762 | ... 763 | ... 3} 764 | ... abc 765 | ... ${"w\nx\nX\ny"} 766 | ... z 767 | ... ''') 768 | >>> f.flush() 769 | >>> result = expand( 770 | ... f.name, 771 | ... line_directive='//#sourceLocation(file: "%(file)s", ' + \ 772 | ... 'line: %(line)d)', 773 | ... x=2 774 | ... ).replace( 775 | ... '"%s"' % f.name.replace('\\', '/'), '"dummy.file"') 776 | >>> print(result, end='') 777 | //#sourceLocation(file: "dummy.file", line: 1) 778 | --- 779 | //#sourceLocation(file: "dummy.file", line: 3) 780 | a pox on 0 for epoxy 781 | //#sourceLocation(file: "dummy.file", line: 3) 782 | a pox on 1 for epoxy 783 | //#sourceLocation(file: "dummy.file", line: 5) 784 | 123 785 | //#sourceLocation(file: "dummy.file", line: 8) 786 | abc 787 | w 788 | x 789 | X 790 | y 791 | //#sourceLocation(file: "dummy.file", line: 10) 792 | z 793 | >>> f.close() 794 | >>> os.remove(f.name) 795 | """ 796 | with io.open(filename, encoding='utf-8') as f: 797 | t = parse_template(filename, f.read()) 798 | d = os.getcwd() 799 | os.chdir(os.path.dirname(os.path.abspath(filename))) 800 | try: 801 | return execute_template( 802 | t, line_directive=line_directive, **local_bindings) 803 | finally: 804 | os.chdir(d) 805 | 806 | 807 | def parse_template(filename, text=None): 808 | r"""Return an AST corresponding to the given template file. 809 | 810 | If text is supplied, it is assumed to be the contents of the file, 811 | as a string. 812 | 813 | >>> print(parse_template('dummy.file', text= 814 | ... '''% for x in [1, 2, 3]: 815 | ... % if x == 1: 816 | ... literal1 817 | ... % elif x > 1: # add output line after this line to fix bug 818 | ... % if x == 2: 819 | ... literal2 820 | ... % end 821 | ... % end 822 | ... % end 823 | ... ''')) 824 | Block: 825 | [ 826 | Code: 827 | { 828 | for x in [1, 2, 3]: 829 | __children__[0].execute(__context__) 830 | } 831 | [ 832 | Block: 833 | [ 834 | Code: 835 | { 836 | if x == 1: 837 | __children__[0].execute(__context__) 838 | elif x > 1: # add output line after this line to fix bug 839 | __children__[1].execute(__context__) 840 | } 841 | [ 842 | Block: 843 | [ 844 | Literal: 845 | literal1 846 | ] 847 | Block: 848 | [ 849 | Code: 850 | { 851 | if x == 2: 852 | __children__[0].execute(__context__) 853 | } 854 | [ 855 | Block: 856 | [ 857 | Literal: 858 | literal2 859 | ] 860 | ] 861 | ] 862 | ] 863 | ] 864 | ] 865 | ] 866 | 867 | >>> print(parse_template( 868 | ... 'dummy.file', 869 | ... text='%for x in range(10):\n% print(x)\n%end\njuicebox')) 870 | Block: 871 | [ 872 | Code: 873 | { 874 | for x in range(10): 875 | __children__[0].execute(__context__) 876 | } 877 | [ 878 | Block: 879 | [ 880 | Code: {print(x)} [] 881 | ] 882 | ] 883 | Literal: 884 | juicebox 885 | ] 886 | 887 | >>> print(parse_template('/dummy.file', text= 888 | ... '''Nothing 889 | ... % if x: 890 | ... % for i in range(3): 891 | ... ${i} 892 | ... % end 893 | ... % else: 894 | ... THIS SHOULD NOT APPEAR IN THE OUTPUT 895 | ... ''')) 896 | Block: 897 | [ 898 | Literal: 899 | Nothing 900 | Code: 901 | { 902 | if x: 903 | __children__[0].execute(__context__) 904 | else: 905 | __children__[1].execute(__context__) 906 | } 907 | [ 908 | Block: 909 | [ 910 | Code: 911 | { 912 | for i in range(3): 913 | __children__[0].execute(__context__) 914 | } 915 | [ 916 | Block: 917 | [ 918 | Code: {(i)} [] 919 | Literal: 920 | 921 | ] 922 | ] 923 | ] 924 | Block: 925 | [ 926 | Literal: 927 | THIS SHOULD NOT APPEAR IN THE OUTPUT 928 | ] 929 | ] 930 | ] 931 | 932 | >>> print(parse_template('dummy.file', text='''% 933 | ... %for x in y: 934 | ... % print(y) 935 | ... ''')) 936 | Block: 937 | [ 938 | Code: 939 | { 940 | for x in y: 941 | __children__[0].execute(__context__) 942 | } 943 | [ 944 | Block: 945 | [ 946 | Code: {print(y)} [] 947 | ] 948 | ] 949 | ] 950 | 951 | >>> print(parse_template('dummy.file', text='''% 952 | ... %if x: 953 | ... % print(y) 954 | ... AAAA 955 | ... %else: 956 | ... BBBB 957 | ... ''')) 958 | Block: 959 | [ 960 | Code: 961 | { 962 | if x: 963 | __children__[0].execute(__context__) 964 | else: 965 | __children__[1].execute(__context__) 966 | } 967 | [ 968 | Block: 969 | [ 970 | Code: {print(y)} [] 971 | Literal: 972 | AAAA 973 | ] 974 | Block: 975 | [ 976 | Literal: 977 | BBBB 978 | ] 979 | ] 980 | ] 981 | 982 | >>> print(parse_template('dummy.file', text='''% 983 | ... %if x: 984 | ... % print(y) 985 | ... AAAA 986 | ... %# This is a comment 987 | ... %else: 988 | ... BBBB 989 | ... ''')) 990 | Block: 991 | [ 992 | Code: 993 | { 994 | if x: 995 | __children__[0].execute(__context__) 996 | # This is a comment 997 | else: 998 | __children__[1].execute(__context__) 999 | } 1000 | [ 1001 | Block: 1002 | [ 1003 | Code: {print(y)} [] 1004 | Literal: 1005 | AAAA 1006 | ] 1007 | Block: 1008 | [ 1009 | Literal: 1010 | BBBB 1011 | ] 1012 | ] 1013 | ] 1014 | 1015 | >>> print(parse_template('dummy.file', text='''\ 1016 | ... %for x in y: 1017 | ... AAAA 1018 | ... %if x: 1019 | ... BBBB 1020 | ... %end 1021 | ... CCCC 1022 | ... ''')) 1023 | Block: 1024 | [ 1025 | Code: 1026 | { 1027 | for x in y: 1028 | __children__[0].execute(__context__) 1029 | } 1030 | [ 1031 | Block: 1032 | [ 1033 | Literal: 1034 | AAAA 1035 | Code: 1036 | { 1037 | if x: 1038 | __children__[0].execute(__context__) 1039 | } 1040 | [ 1041 | Block: 1042 | [ 1043 | Literal: 1044 | BBBB 1045 | ] 1046 | ] 1047 | Literal: 1048 | CCCC 1049 | ] 1050 | ] 1051 | ] 1052 | """ 1053 | return Block(ParseContext(filename, text)) 1054 | 1055 | 1056 | def execute_template( 1057 | ast, line_directive=_default_line_directive, **local_bindings): 1058 | r"""Return the text generated by executing the given template AST. 1059 | 1060 | Keyword arguments become local variable bindings in the execution context 1061 | 1062 | >>> root_directory = os.path.abspath('/') 1063 | >>> file_name = (root_directory + 'dummy.file').replace('\\', '/') 1064 | >>> ast = parse_template(file_name, text= 1065 | ... '''Nothing 1066 | ... % if x: 1067 | ... % for i in range(3): 1068 | ... ${i} 1069 | ... % end 1070 | ... % else: 1071 | ... THIS SHOULD NOT APPEAR IN THE OUTPUT 1072 | ... ''') 1073 | >>> out = execute_template(ast, 1074 | ... line_directive='//#sourceLocation(file: "%(file)s", line: %(line)d)', 1075 | ... x=1) 1076 | >>> out = out.replace(file_name, "DUMMY-FILE") 1077 | >>> print(out, end="") 1078 | //#sourceLocation(file: "DUMMY-FILE", line: 1) 1079 | Nothing 1080 | //#sourceLocation(file: "DUMMY-FILE", line: 4) 1081 | 0 1082 | //#sourceLocation(file: "DUMMY-FILE", line: 4) 1083 | 1 1084 | //#sourceLocation(file: "DUMMY-FILE", line: 4) 1085 | 2 1086 | 1087 | >>> ast = parse_template(file_name, text= 1088 | ... '''Nothing 1089 | ... % a = [] 1090 | ... % for x in range(3): 1091 | ... % a.append(x) 1092 | ... % end 1093 | ... ${a} 1094 | ... ''') 1095 | >>> out = execute_template(ast, 1096 | ... line_directive='//#sourceLocation(file: "%(file)s", line: %(line)d)', 1097 | ... x=1) 1098 | >>> out = out.replace(file_name, "DUMMY-FILE") 1099 | >>> print(out, end="") 1100 | //#sourceLocation(file: "DUMMY-FILE", line: 1) 1101 | Nothing 1102 | //#sourceLocation(file: "DUMMY-FILE", line: 6) 1103 | [0, 1, 2] 1104 | 1105 | >>> ast = parse_template(file_name, text= 1106 | ... '''Nothing 1107 | ... % a = [] 1108 | ... % for x in range(3): 1109 | ... % a.append(x) 1110 | ... % end 1111 | ... ${a} 1112 | ... ''') 1113 | >>> out = execute_template(ast, 1114 | ... line_directive='#line %(line)d "%(file)s"', x=1) 1115 | >>> out = out.replace(file_name, "DUMMY-FILE") 1116 | >>> print(out, end="") 1117 | #line 1 "DUMMY-FILE" 1118 | Nothing 1119 | #line 6 "DUMMY-FILE" 1120 | [0, 1, 2] 1121 | """ 1122 | execution_context = ExecutionContext( 1123 | line_directive=line_directive, **local_bindings) 1124 | ast.execute(execution_context) 1125 | return ''.join(execution_context.result_text) 1126 | 1127 | 1128 | def main(): 1129 | import argparse 1130 | import sys 1131 | 1132 | parser = argparse.ArgumentParser( 1133 | formatter_class=argparse.RawDescriptionHelpFormatter, 1134 | description='Generate Your Boilerplate!', epilog=''' 1135 | A GYB template consists of the following elements: 1136 | 1137 | - Literal text which is inserted directly into the output 1138 | 1139 | - %% or $$ in literal text, which insert literal '%' and '$' 1140 | symbols respectively. 1141 | 1142 | - Substitutions of the form ${}. The Python 1143 | expression is converted to a string and the result is inserted 1144 | into the output. 1145 | 1146 | - Python code delimited by %{...}%. Typically used to inject 1147 | definitions (functions, classes, variable bindings) into the 1148 | evaluation context of the template. Common indentation is 1149 | stripped, so you can add as much indentation to the beginning 1150 | of this code as you like 1151 | 1152 | - Lines beginning with optional whitespace followed by a single 1153 | '%' and Python code. %-lines allow you to nest other 1154 | constructs inside them. To close a level of nesting, use the 1155 | "%end" construct. 1156 | 1157 | - Lines beginning with optional whitespace and followed by a 1158 | single '%' and the token "end", which close open constructs in 1159 | %-lines. 1160 | 1161 | Example template: 1162 | 1163 | - Hello - 1164 | %{ 1165 | x = 42 1166 | def succ(a): 1167 | return a+1 1168 | }% 1169 | 1170 | I can assure you that ${x} < ${succ(x)} 1171 | 1172 | % if int(y) > 7: 1173 | % for i in range(3): 1174 | y is greater than seven! 1175 | % end 1176 | % else: 1177 | y is less than or equal to seven 1178 | % end 1179 | 1180 | - The End. - 1181 | 1182 | When run with "gyb -Dy=9", the output is 1183 | 1184 | - Hello - 1185 | 1186 | I can assure you that 42 < 43 1187 | 1188 | y is greater than seven! 1189 | y is greater than seven! 1190 | y is greater than seven! 1191 | 1192 | - The End. - 1193 | ''' 1194 | ) 1195 | parser.add_argument( 1196 | '-D', action='append', dest='defines', metavar='NAME=VALUE', 1197 | default=[], 1198 | help='''Bindings to be set in the template's execution context''') 1199 | 1200 | parser.add_argument( 1201 | 'file', type=str, 1202 | help='Path to GYB template file (defaults to stdin)', nargs='?', 1203 | default='-') 1204 | parser.add_argument( 1205 | '-o', dest='target', type=str, 1206 | help='Output file (defaults to stdout)', default='-') 1207 | parser.add_argument( 1208 | '--test', action='store_true', 1209 | default=False, help='Run a self-test') 1210 | parser.add_argument( 1211 | '--verbose-test', action='store_true', 1212 | default=False, help='Run a verbose self-test') 1213 | parser.add_argument( 1214 | '--dump', action='store_true', 1215 | default=False, help='Dump the parsed template to stdout') 1216 | parser.add_argument( 1217 | '--line-directive', 1218 | default=_default_line_directive, 1219 | help=''' 1220 | Line directive format string, which will be 1221 | provided 2 substitutions, `%%(line)d` and `%%(file)s`. 1222 | 1223 | Example: `#sourceLocation(file: "%%(file)s", line: %%(line)d)` 1224 | 1225 | The default works automatically with the `line-directive` tool, 1226 | which see for more information. 1227 | ''') 1228 | 1229 | args = parser.parse_args(sys.argv[1:]) 1230 | 1231 | if args.test or args.verbose_test: 1232 | import doctest 1233 | selfmod = sys.modules[__name__] 1234 | if doctest.testmod(selfmod, verbose=args.verbose_test or None).failed: 1235 | sys.exit(1) 1236 | 1237 | bindings = dict(x.split('=', 1) for x in args.defines) 1238 | if args.file == '-': 1239 | ast = parse_template('stdin', sys.stdin.read()) 1240 | else: 1241 | with io.open(os.path.normpath(args.file), 'r', encoding='utf-8') as f: 1242 | ast = parse_template(args.file, f.read()) 1243 | if args.dump: 1244 | print(ast) 1245 | # Allow the template to open files and import .py files relative to its own 1246 | # directory 1247 | os.chdir(os.path.dirname(os.path.abspath(args.file))) 1248 | sys.path = ['.'] + sys.path 1249 | 1250 | if args.target == '-': 1251 | sys.stdout.write(execute_template(ast, args.line_directive, **bindings)) 1252 | else: 1253 | with io.open(args.target, 'w', encoding='utf-8', newline='\n') as f: 1254 | f.write(execute_template(ast, args.line_directive, **bindings)) 1255 | 1256 | 1257 | if __name__ == '__main__': 1258 | main() 1259 | -------------------------------------------------------------------------------- /mktemplates: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | gyb="./Vendor/gyb" 4 | 5 | function gyb() { 6 | find $1 -iname '*.gyb' | while read file; do 7 | target="${file%.gyb}" 8 | test -f "$target" && rm "$target" 9 | $gyb --line-directive '' "$file" > $target 10 | done 11 | } 12 | 13 | gyb Sources 14 | gyb Tests 15 | --------------------------------------------------------------------------------