├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── SchemaSwift │ ├── Database.swift │ └── main.swift └── SchemaSwiftLibrary │ └── Model │ ├── Column.swift │ ├── Enum.swift │ ├── Inflectors.swift │ ├── Overrides.swift │ └── Table.swift └── Tests ├── LinuxMain.swift └── SchemaSwiftTests ├── InflectionTests.swift └── XCTestManifests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm/** 7 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Socket", 6 | "repositoryURL": "https://github.com/IBM-Swift/BlueSocket.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "dd924c3bc2c1c144c42b8dda3896f1a03115ded4", 10 | "version": "2.0.2" 11 | } 12 | }, 13 | { 14 | "package": "SSLService", 15 | "repositoryURL": "https://github.com/IBM-Swift/BlueSSLService", 16 | "state": { 17 | "branch": null, 18 | "revision": "c249988fb748749739144e7f554710552acdc0bd", 19 | "version": "2.0.1" 20 | } 21 | }, 22 | { 23 | "package": "PostgresClientKit", 24 | "repositoryURL": "https://github.com/codewinsdotcom/PostgresClientKit", 25 | "state": { 26 | "branch": null, 27 | "revision": "beafedaea6dc9f04712e9a8547b77f47c406a47e", 28 | "version": "1.4.3" 29 | } 30 | }, 31 | { 32 | "package": "swift-argument-parser", 33 | "repositoryURL": "https://github.com/apple/swift-argument-parser", 34 | "state": { 35 | "branch": null, 36 | "revision": "6b2aa2748a7881eebb9f84fb10c01293e15b52ca", 37 | "version": "0.5.0" 38 | } 39 | } 40 | ] 41 | }, 42 | "version": 1 43 | } 44 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SchemaSwift", 7 | products: [ 8 | .executable(name: "SchemaSwift", targets: ["SchemaSwift"]), 9 | .library(name: "SchemaSwiftLibrary", targets: ["SchemaSwiftLibrary"]), 10 | ], 11 | dependencies: [ 12 | .package(name: "swift-argument-parser", url: "https://github.com/apple/swift-argument-parser", from: "0.3.0"), 13 | .package(url: "https://github.com/codewinsdotcom/PostgresClientKit", from: "1.4.3"), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "SchemaSwiftLibrary", 18 | dependencies: [ 19 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 20 | .product(name: "PostgresClientKit", package: "PostgresClientKit"), 21 | ]), 22 | .target( 23 | name: "SchemaSwift", 24 | dependencies: ["SchemaSwiftLibrary"]), 25 | .testTarget( 26 | name: "SchemaSwiftTests", 27 | dependencies: ["SchemaSwiftLibrary"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SchemaSwift 2 | 3 | A Swift port of [schemats](https://github.com/SweetIQ/schemats/). Generates Swift structs from your PostgreSQL schema. 4 | 5 | SchemaSwift is intended to be run as a command line tool. 6 | 7 | ``` 8 | SchemaSwift --url \ 9 | --override users.email=Email \ 10 | --swift-namespace DB \ 11 | --protocols "Equatable, Hashable, Identifiable" 12 | ``` 13 | 14 | ## Available options: 15 | 16 | ### url 17 | 18 | Required, a URL to a Postgres instance. 19 | 20 | ### output, o 21 | The location of the file containing the output. Will output to stdout if a file is not specified. 22 | 23 | ### schema 24 | The schema in the database to generate models for. Will default to "public" if not specified. 25 | 26 | ### protocols 27 | 28 | A list of comma separated protocols to apply to each record struct. Codable conformance is always included. Will default to adding \"Equatable, Hashable\" if not specified. 29 | 30 | ### swift-namespace 31 | An empty enum that acts as a namespace that all types will go inside. If not specified, types will not be placed inside an enum. 32 | 33 | ### override 34 | Overrides for the generated types. Must be in the format `table.column=Type`. May include multiple overrides. 35 | -------------------------------------------------------------------------------- /Sources/SchemaSwift/Database.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Database.swift 3 | // 4 | // 5 | // Created by Soroush Khanlou on 11/1/20. 6 | // 7 | 8 | import Foundation 9 | import PostgresClientKit 10 | import SchemaSwiftLibrary 11 | 12 | extension Connection { 13 | @discardableResult 14 | func execute(_ sql: String, _ parameters: [PostgresValueConvertible] = []) throws -> Cursor { 15 | let statement = try self.prepareStatement(text: sql) 16 | let cursor = try statement.execute(parameterValues: parameters, retrieveColumnMetadata: true) 17 | return cursor 18 | } 19 | } 20 | 21 | extension ConnectionConfiguration { 22 | init(url: String) { 23 | self.init() 24 | let components = URLComponents(string: url) 25 | self.host = components?.host ?? "" 26 | self.database = String(components?.path.dropFirst() ?? "") 27 | self.user = components?.user ?? "" 28 | self.credential = .md5Password(password: components?.password ?? "") 29 | } 30 | } 31 | 32 | struct Database { 33 | 34 | let connection: Connection 35 | 36 | init(url: String) throws { 37 | connection = try Connection(configuration: .init(url: url)) 38 | } 39 | 40 | func fetchEnumTypes(schema: String) throws -> [EnumDefinition] { 41 | try connection 42 | .execute(""" 43 | SELECT n.nspname AS SCHEMA, t.typname AS name, e.enumlabel AS value 44 | FROM pg_type t 45 | JOIN pg_enum e ON t.oid = e.enumtypid 46 | JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace 47 | WHERE n.nspname = $1 48 | ORDER BY t.typname asc, e.enumlabel asc; 49 | """, [schema]) 50 | .map({ try $0.get() }) 51 | .reduce(into: [String: [String]](), { acc, row in 52 | let name = try row.columns[1].string() 53 | let value = try row.columns[2].string() 54 | acc[name, default: []].append(value) 55 | }) 56 | .map({ name, values in 57 | EnumDefinition(name: name, values: values) 58 | }) 59 | .sorted(by: { $0.name < $1.name }) 60 | } 61 | 62 | func fetchTableNames(schema: String) throws -> [String] { 63 | try connection 64 | .execute("SELECT tablename FROM pg_catalog.pg_tables WHERE (schemaname = $1) AND schemaname != 'pg_catalog' AND schemaname != 'information_schema' group by tablename;", [schema]) 65 | .map({ try $0.get() }) 66 | .compactMap({ row in 67 | return try? row.columns[0].string() 68 | }) 69 | .sorted() 70 | } 71 | 72 | func fetchTableDefinition(tableName: String) throws -> TableDefinition { 73 | let columns = try connection 74 | .execute("SELECT column_name, udt_name, is_nullable FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position", [tableName]) 75 | .map({ try $0.get() }) 76 | .map({ row in 77 | return try Column( 78 | name: row.columns[0].string(), 79 | udtName: row.columns[1].string(), 80 | isNullable: row.columns[2].string() == "YES" 81 | ) 82 | }) 83 | return TableDefinition(name: tableName, columns: columns) 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /Sources/SchemaSwift/main.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import SchemaSwiftLibrary 3 | import Foundation 4 | 5 | struct SchemaSwift: ParsableCommand { 6 | static var configuration = CommandConfiguration( 7 | abstract: "A utility for generating Swift row structs from a Postgres schema.", 8 | version: "1.0.0", 9 | subcommands: [Generate.self], 10 | defaultSubcommand: Generate.self 11 | ) 12 | } 13 | 14 | struct Generate: ParsableCommand { 15 | @Option(help: "The full url for the Postgres server, with username, password, database name, and port.") 16 | var url: String 17 | 18 | @Option( 19 | name: [.customShort("o"), .long], 20 | help: "The location of the file containing the output. Will output to stdout if a file is not specified." 21 | ) 22 | var output: String? 23 | 24 | @Option( 25 | help: "The schema in the database to generate models for. Will default to \"public\" if not specified." 26 | ) 27 | var schema: String = "public" 28 | 29 | @Option( 30 | help: "A list of comma separated protocols to apply to each record struct. Codable conformance is always included. Will default to adding \"Equatable, Hashable\" if not specified." 31 | ) 32 | var protocols: String = "Equatable, Hashable" 33 | 34 | @Option( 35 | help: "An empty enum that acts as a namespace that all types will go inside." 36 | ) 37 | var swiftNamespace: String = "" 38 | 39 | @Option( 40 | help: "Overrides for the generated types. Must be in the format `table.column=Type`. May include multiple overrides." 41 | ) 42 | var override: [String] = [] 43 | 44 | func run() throws { 45 | 46 | let overrides = Overrides(overrides: override) 47 | let database = try Database(url: url) 48 | 49 | let enums = try database.fetchEnumTypes(schema: schema) 50 | 51 | let tables = try database.fetchTableNames(schema: schema).map({ try database.fetchTableDefinition(tableName: $0) }) 52 | 53 | var header = """ 54 | /** 55 | * AUTO-GENERATED FILE - \(Date()) - DO NOT EDIT! 56 | * 57 | * This file was automatically generated by SchemaSwift 58 | * 59 | */ 60 | 61 | import Foundation 62 | 63 | 64 | """ 65 | 66 | if !swiftNamespace.isEmpty { 67 | header += """ 68 | enum \(swiftNamespace) { 69 | 70 | 71 | """ 72 | } 73 | 74 | var body = "" 75 | 76 | for enumDefinition in enums { 77 | body += """ 78 | enum \(Inflections.upperCamelCase(Inflections.singularize(enumDefinition.name))): String, Codable, CaseIterable { 79 | static let enumName = "\(enumDefinition.name)" 80 | 81 | 82 | """ 83 | 84 | for value in enumDefinition.values.sorted() { 85 | body += """ 86 | case \(Inflections.lowerCamelCase(normalizedForReservedKeywords(value))) = "\(value)" 87 | 88 | """ 89 | 90 | } 91 | 92 | body += """ 93 | } 94 | 95 | 96 | """ 97 | } 98 | 99 | for table in tables { 100 | body += """ 101 | struct \(Inflections.upperCamelCase(Inflections.singularize(table.name))): Codable\(protocols.isEmpty ? "" : ", \(protocols)") { 102 | static let tableName = "\(table.name)" 103 | 104 | 105 | """ 106 | 107 | let overrides = overrides.overrides(forTable: table.name) 108 | 109 | for column in table.columns { 110 | body += """ 111 | let \(Inflections.lowerCamelCase(normalizedForReservedKeywords(column.name))): \(column.swiftType(enums: enums, overrides: overrides)) 112 | 113 | """ 114 | } 115 | 116 | body += """ 117 | 118 | enum CodingKeys: String, CodingKey { 119 | 120 | """ 121 | 122 | for column in table.columns { 123 | body += """ 124 | case \(Inflections.lowerCamelCase(normalizedForReservedKeywords(column.name))) = "\(column.name)" 125 | 126 | """ 127 | } 128 | body += """ 129 | } 130 | 131 | """ 132 | 133 | 134 | body += """ 135 | } 136 | 137 | 138 | """ 139 | } 140 | 141 | var footer = "" 142 | if !swiftNamespace.isEmpty { 143 | body = body 144 | .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) 145 | .map({ " " + $0 }) 146 | .joined(separator: "\n") 147 | 148 | body.removeLast(9) // trim trailing whitespace 149 | 150 | footer += "}" 151 | } 152 | 153 | let completeString = header + body + footer 154 | 155 | if let outputPath = output { 156 | let url = URL(fileURLWithPath: outputPath) 157 | try completeString.write(to: url, atomically: true, encoding: .utf8) 158 | } else { 159 | print(completeString) 160 | } 161 | } 162 | } 163 | 164 | SchemaSwift.main() 165 | -------------------------------------------------------------------------------- /Sources/SchemaSwiftLibrary/Model/Column.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Column.swift 3 | // 4 | // 5 | // Created by Soroush Khanlou on 11/1/20. 6 | // 7 | 8 | import Foundation 9 | 10 | let reservedKeywords: Set = [ 11 | "public", 12 | "operator", 13 | ] 14 | 15 | public func normalizedForReservedKeywords(_ name: String) -> String { 16 | if reservedKeywords.contains(name) { 17 | return "`\(name)`" 18 | } else { 19 | return name 20 | } 21 | } 22 | 23 | public struct Column { 24 | public init(name: String, udtName: String, isNullable: Bool) { 25 | self.name = name 26 | self.udtName = udtName 27 | self.isNullable = isNullable 28 | } 29 | 30 | public let name: String 31 | public let udtName: String 32 | public let isNullable: Bool 33 | 34 | // https://www.postgresql.org/docs/9.5/datatype.html 35 | // https://github.com/SweetIQ/schemats/blob/master/src/schemaPostgres.ts#L17-L93 36 | public func swiftType(enums: [EnumDefinition], overrides: [String: String]) -> String { 37 | 38 | let matchingOverride = overrides.first(where: { $0.key == self.name })?.value 39 | let type = matchingOverride ?? swiftTypeIfKnown(enums: enums) 40 | let nullableSuffix = isNullable ? "?" : "" 41 | let comment = type == nil ? " //Unknown postgres type: \(udtName)" : "" 42 | return "\(type ?? "String")\(nullableSuffix)\(comment)" 43 | } 44 | 45 | func swiftTypeIfKnown(enums: [EnumDefinition]) -> String? { 46 | switch udtName { 47 | case "bpchar", "char", "varchar", "text", "citext", "bytea", "inet", "time", "timetz", "interval", "name": 48 | return "String" 49 | case "uuid": 50 | return "UUID" 51 | case "int2", "int4", "int8": 52 | return "Int" 53 | case "float4", "float8": 54 | return "Double" 55 | case "bool": 56 | return "Bool" 57 | case "date", "timestamp", "timestamptz": 58 | return "Date" 59 | case "_int2", "_int4", "_int8": 60 | return "[Int]" 61 | case "_float4", "_float8": 62 | return "[Double]" 63 | case "_bool": 64 | return "[Bool]" 65 | case "_varchar", "_text", "_citext", "_bytea": 66 | return "[String]" 67 | case "_uuid": 68 | return "[UUID]" 69 | case "_timestamptz": 70 | return "[Date]" 71 | case "numeric", "money", "_numeric", "_money", "oid", "json", "jsonb", "_json", "_jsonb": 72 | break 73 | default: 74 | break 75 | } 76 | 77 | if let enumDefinition = enums.first(where: { $0.name == udtName }) { 78 | return Inflections.upperCamelCase(Inflections.singularize(enumDefinition.name)) 79 | } 80 | 81 | return nil 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/SchemaSwiftLibrary/Model/Enum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Soroush Khanlou on 12/21/20. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct EnumDefinition { 11 | 12 | public let name: String 13 | public let values: [String] 14 | 15 | public init(name: String, values: [String]) { 16 | self.name = name 17 | self.values = values 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SchemaSwiftLibrary/Model/Inflectors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Soroush Khanlou on 11/1/20. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum Inflections { 11 | public static func upperCamelCase(_ stringKey: String) -> String { 12 | camelCase(stringKey, uppercaseFirstLetter: true) 13 | } 14 | 15 | public static func lowerCamelCase(_ stringKey: String) -> String { 16 | camelCase(stringKey, uppercaseFirstLetter: false) 17 | } 18 | 19 | // pulled from https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/JSONEncoder.swift#L1061-L1105 20 | static func camelCase(_ stringKey: String, uppercaseFirstLetter: Bool) -> String { 21 | guard !stringKey.isEmpty else { return stringKey } 22 | 23 | // Find the first non-underscore character 24 | guard let firstNonUnderscore = stringKey.firstIndex(where: { $0 != "_" }) else { 25 | // Reached the end without finding an _ 26 | return stringKey 27 | } 28 | 29 | // Find the last non-underscore character 30 | var lastNonUnderscore = stringKey.index(before: stringKey.endIndex) 31 | while lastNonUnderscore > firstNonUnderscore && stringKey[lastNonUnderscore] == "_" { 32 | stringKey.formIndex(before: &lastNonUnderscore) 33 | } 34 | 35 | let keyRange = firstNonUnderscore...lastNonUnderscore 36 | let leadingUnderscoreRange = stringKey.startIndex.. String { 74 | Pluralizer.shared.singularize(string: original) 75 | } 76 | 77 | public static func pluralize(_ original: String) -> String { 78 | Pluralizer.shared.pluralize(string: original) 79 | } 80 | } 81 | 82 | // pulled from https://github.com/draveness/RbSwift/blob/f4492d4dfbd5ecc6b299e424bfd7f36aa619098c/RbSwift/Inflector/Inflector.swift 83 | struct InflectorRule { 84 | var rule: String 85 | var replacement: String 86 | let regex: NSRegularExpression 87 | 88 | init(rule: String, replacement: String) { 89 | self.rule = rule 90 | self.replacement = replacement 91 | self.regex = try! NSRegularExpression(pattern: rule, options: .caseInsensitive) 92 | } 93 | } 94 | 95 | class Pluralizer { 96 | 97 | static let shared = Pluralizer() 98 | 99 | private let pluralRules: [InflectorRule] 100 | private let singularRules: [InflectorRule] 101 | private let words: Set 102 | 103 | init() { 104 | 105 | let uncountables = ["access", "accommodation", "adulthood", "advertising", "advice", "aggression", "aid", "air", "alcohol", "anger", "applause", "arithmetic", "art", "assistance", "athletics", "attention", "bacon", "baggage", "ballet", "beauty", "beef", "beer", "biology", "botany", "bread", "butter", "carbon", "cash", "chaos", "cheese", "chess", "childhood", "clothing", "coal", "coffee", "commerce", "compassion", "comprehension", "content", "corruption", "cotton", "courage", "cream", "currency", "dancing", "danger", "data", "delight", "dignity", "dirt", "distribution", "dust", "economics", "education", "electricity", "employment", "engineering", "envy", "equipment", "ethics", "evidence", "evolution", "faith", "fame", "fish", "flour", "flu", "food", "freedom", "fuel", "fun", "furniture", "garbage", "garlic", "genetics", "gold", "golf", "gossip", "grammar", "gratitude", "grief", "ground", "guilt", "gymnastics", "hair", "happiness", "hardware", "harm", "hate", "hatred", "health", "heat", "height", "help", "homework", "honesty", "honey", "hospitality", "housework", "humour", "hunger", "hydrogen", "ice", "importance", "inflation", "information", "injustice", "innocence", "iron", "irony", "jealousy", "jeans", "jelly", "judo", "karate", "kindness", "knowledge", "labour", "lack", "laughter", "lava", "leather", "leisure", "lightning", "linguistics", "litter", "livestock", "logic", "loneliness", "luck", "luggage", "machinery", "magic", "management", "mankind", "marble", "mathematics", "mayonnaise", "measles", "meat", "methane", "milk", "money", "mud", "music", "nature", "news", "nitrogen", "nonsense", "nurture", "nutrition", "obedience", "obesity", "oil", "oxygen", "passion", "pasta", "patience", "permission", "physics", "poetry", "police", "pollution", "poverty", "power", "pronunciation", "psychology", "publicity", "quartz", "racism", "rain", "relaxation", "reliability", "research", "respect", "revenge", "rice", "rubbish", "rum", "salad", "satire", "seaside", "series", "shame", "sheep", "shopping", "silence", "sleep", "smoke", "smoking", "snow", "soap", "software", "soil", "sorrow", "soup", "species", "speed", "spelling", "steam", "stuff", "stupidity", "sunshine", "symmetry", "tennis", "thirst", "thunder", "toast", "tolerance", "toys", "traffic", "transporation", "travel", "trust", "understanding", "unemployment", "unity", "validity", "veal", "vengeance", "violence"] 106 | 107 | let singularToPlural = [ 108 | ("$", "s"), 109 | ("s$", "s"), 110 | ("^(ax|test)is$", "$1es"), 111 | ("(octop|vir)us$", "$1i"), 112 | ("(octop|vir)i$", "$1i"), 113 | ("(alias|status)$", "$1es"), 114 | ("(bu)s$", "$1ses"), 115 | ("(buffal|tomat)o$", "$1oes"), 116 | ("([ti])um$", "$1a"), 117 | ("([ti])a$", "$1a"), 118 | ("sis$", "ses"), 119 | ("(?:([^f])fe|([lr])f)$", "$1$2ves"), 120 | ("(hive)$", "$1s"), 121 | ("([^aeiouy]|qu)y$", "$1ies"), 122 | ("(x|ch|ss|sh)$", "$1es"), 123 | ("(matr|vert|ind)(?:ix|ex)$", "$1ices"), 124 | ("^(m|l)ouse$", "$1ice"), 125 | ("^(m|l)ice$", "$1ice"), 126 | ("^(ox)$", "$1en"), 127 | ("^(oxen)$", "$1"), 128 | ("(quiz)$", "$1zes"), 129 | ] 130 | 131 | let pluralToSingular = [ 132 | ("s$", ""), 133 | ("(ss)$", "$1"), 134 | ("(n)ews$", "$1ews"), 135 | ("([ti])a$", "$1um"), 136 | ("((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$", "$1sis"), 137 | ("(^analy)(sis|ses)$", "$1sis"), 138 | ("([^f])ves$", "$1fe"), 139 | ("(hive)s$", "$1"), 140 | ("(tive)s$", "$1"), 141 | ("([lr])ves$", "$1f"), 142 | ("([^aeiouy]|qu)ies$", "$1y"), 143 | ("(s)eries$", "$1eries"), 144 | ("(m)ovies$", "$1ovie"), 145 | ("(x|ch|ss|sh)es$", "$1"), 146 | ("^(m|l)ice$", "$1ouse"), 147 | ("(bus)(es)?$", "$1"), 148 | ("(o)es$", "$1"), 149 | ("(shoe)s$", "$1"), 150 | ("(cris|test)(is|es)$", "$1is"), 151 | ("^(a)x[ie]s$", "$1xis"), 152 | ("(octop|vir)(us|i)$", "$1us"), 153 | ("(alias|status)(es)?$", "$1"), 154 | ("^(ox)en", "$1"), 155 | ("(vert|ind)ices$", "$1ex"), 156 | ("(matr)ices$", "$1ix"), 157 | ("(quiz)zes$", "$1"), 158 | ("(database)s$", "$1"), 159 | ] 160 | 161 | let unchangings = [ 162 | "sheep", 163 | "deer", 164 | "moose", 165 | "swine", 166 | "bison", 167 | "corps", 168 | "means", 169 | "series", 170 | "scissors", 171 | "species", 172 | ] 173 | 174 | let irregulars = [ 175 | "person": "people", 176 | "man": "men", 177 | "child": "children", 178 | "sex": "sexes", 179 | "move": "moves", 180 | "zombie": "zombies", 181 | ] 182 | 183 | var pluralRules: [InflectorRule] = [] 184 | var singularRules: [InflectorRule] = [] 185 | var words: Set = Set() 186 | 187 | func addIrregularRule(singular: String, andPlural plural: String) { 188 | let singularRule: String = "\(plural)$" 189 | addSingularRule(rule: singularRule, forReplacement: singular) 190 | let pluralRule: String = "\(singular)$" 191 | addPluralRule(rule: pluralRule, forReplacement: plural) 192 | } 193 | 194 | func addSingularRule(rule: String, forReplacement replacement: String) { 195 | singularRules.append(InflectorRule(rule: rule, replacement: replacement)) 196 | } 197 | 198 | func addPluralRule(rule: String, forReplacement replacement: String) { 199 | pluralRules.append(InflectorRule(rule: rule, replacement: replacement)) 200 | } 201 | 202 | irregulars.forEach { (key, value) in 203 | addIrregularRule(singular: key, andPlural: value) 204 | } 205 | 206 | singularToPlural.reversed().forEach { (key, value) in 207 | addPluralRule(rule: key, forReplacement: value) 208 | } 209 | 210 | pluralToSingular.reversed().forEach { (key, value) in 211 | addSingularRule(rule: key, forReplacement: value) 212 | } 213 | 214 | unchangings.forEach { words.insert($0) } 215 | uncountables.forEach { words.insert($0) } 216 | 217 | self.pluralRules = pluralRules 218 | self.singularRules = singularRules 219 | self.words = words 220 | } 221 | 222 | func pluralize(string: String) -> String { 223 | return apply(rules: pluralRules, forString: string) 224 | } 225 | 226 | func singularize(string: String) -> String { 227 | return apply(rules: singularRules, forString: string) 228 | } 229 | 230 | private func apply(rules: [InflectorRule], forString string: String) -> String { 231 | guard !words.contains(string) else { 232 | return string 233 | } 234 | 235 | let range = NSMakeRange(0, string.utf16.count) 236 | 237 | let matchingRule = rules.first(where: { $0.regex.firstMatch(in: string, range: range) != nil }) 238 | 239 | if let matchingRule = matchingRule { 240 | return matchingRule.regex.stringByReplacingMatches(in: string, range: range, withTemplate: matchingRule.replacement) 241 | } else { 242 | return string 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /Sources/SchemaSwiftLibrary/Model/Overrides.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Overrides.swift 3 | // 4 | // 5 | // Created by Soroush Khanlou on 1/22/21. 6 | // 7 | 8 | public struct Overrides { 9 | 10 | //Tables to columns to types 11 | public let overrides: [String: [String: String]] 12 | 13 | public init(overrides: [String]) { 14 | self.overrides = overrides.reduce(into: [String: [String: String]](), { acc, override in 15 | let components1 = override.split(separator: "=") 16 | guard components1.count == 2 else { 17 | fatalError("Malformed override key. Must be in the format table.column=Type.") 18 | } 19 | let columnPath = components1[0] 20 | let type = components1[1] 21 | let components2 = columnPath.split(separator: ".") 22 | // Should this handle schemata as well? 23 | guard components2.count == 2 else { 24 | fatalError("Malformed override key. Must be in the format table.column=Type.") 25 | } 26 | let tableName = String(components2[0]) 27 | let columnName = String(components2[1]) 28 | acc[tableName, default: .init()][columnName, default: .init()] = String(type) 29 | }) 30 | } 31 | 32 | public func overrides(forTable table: String) -> [String: String] { 33 | overrides[table, default: .init()] 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /Sources/SchemaSwiftLibrary/Model/Table.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Table.swift 3 | // 4 | // 5 | // Created by Soroush Khanlou on 11/1/20. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct TableDefinition { 11 | public let name: String 12 | public let columns: [Column] 13 | 14 | public init(name: String, columns: [Column]) { 15 | self.name = name 16 | self.columns = columns 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SchemaSwiftTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SchemaSwiftTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SchemaSwiftTests/InflectionTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SchemaSwiftLibrary 3 | 4 | final class InflectionTests: XCTestCase { 5 | func testUpperCamelCase() { 6 | XCTAssertEqual(Inflections.upperCamelCase("users"), "Users") 7 | XCTAssertEqual(Inflections.upperCamelCase("one_time_passwords"), "OneTimePasswords") 8 | XCTAssertEqual(Inflections.upperCamelCase("_users"), "_Users") 9 | XCTAssertEqual(Inflections.upperCamelCase("avatar_url"), "AvatarUrl") 10 | } 11 | 12 | func testLowerCamelCase() { 13 | XCTAssertEqual(Inflections.lowerCamelCase("users"), "users") 14 | XCTAssertEqual(Inflections.lowerCamelCase("one_time_passwords"), "oneTimePasswords") 15 | XCTAssertEqual(Inflections.lowerCamelCase("_users"), "_users") 16 | XCTAssertEqual(Inflections.lowerCamelCase("avatar_url"), "avatarUrl") 17 | } 18 | 19 | func testSingularize() { 20 | XCTAssertEqual(Inflections.singularize("people"), "person") 21 | XCTAssertEqual(Inflections.singularize("monkeys"), "monkey") 22 | XCTAssertEqual(Inflections.singularize("users"), "user") 23 | XCTAssertEqual(Inflections.singularize("men"), "man") 24 | } 25 | 26 | func testPluralize() { 27 | XCTAssertEqual(Inflections.pluralize("person"), "people") 28 | XCTAssertEqual(Inflections.pluralize("monkey"), "monkeys") 29 | XCTAssertEqual(Inflections.pluralize("user"), "users") 30 | XCTAssertEqual(Inflections.pluralize("man"), "men") 31 | } 32 | 33 | func testCombo() { 34 | XCTAssertEqual(Inflections.singularize(Inflections.upperCamelCase("time_entries")), "TimeEntry") 35 | XCTAssertEqual(Inflections.upperCamelCase(Inflections.singularize("time_entries")), "TimeEntry") 36 | } 37 | 38 | static var allTests = [ 39 | ("testUpperCamelCase", testUpperCamelCase), 40 | ("testLowerCamelCase", testLowerCamelCase), 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /Tests/SchemaSwiftTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(InflectionTests.allTests), 7 | ] 8 | } 9 | #endif 10 | --------------------------------------------------------------------------------