diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5972b74 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: release +on: + release: + types: [published] + +jobs: + update: + name: build + runs-on: macOS-latest + steps: + - name: ⬇️ Checkout + uses: actions/checkout@master + with: + fetch-depth: 1 + - name: 🏗 swiftbuild + run: | + swift build -c debug + - name: 📦 Build archive + run: | + REPOSITORY_NAME=$(jq --raw-output '.repository.name' $GITHUB_EVENT_PATH) + zip -r $REPOSITORY_NAME.zip .build/debug/$REPOSITORY_NAME + - name: ⬆️ Upload to Release + run: | + REPOSITORY_NAME=$(jq --raw-output '.repository.name' $GITHUB_EVENT_PATH) + ARTIFACT=./$REPOSITORY_NAME.zip + AUTH_HEADER="Authorization: token $GITHUB_TOKEN" + CONTENT_LENGTH_HEADER="Content-Length: $(stat -f%z "$ARTIFACT")" + CONTENT_TYPE_HEADER="Content-Type: application/zip" + RELEASE_ID=$(jq --raw-output '.release.id' $GITHUB_EVENT_PATH) + FILENAME=$(basename $ARTIFACT) + UPLOAD_URL="https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$FILENAME" + echo "$UPLOAD_URL" + curl -sSL -XPOST \ + -H "$AUTH_HEADER" -H "$CONTENT_LENGTH_HEADER" -H "$CONTENT_TYPE_HEADER" \ + --upload-file "$ARTIFACT" "$UPLOAD_URL" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..a7c70de --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,17 @@ +name: Swift + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v2 + - name: Build + run: swift build -v diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..7fc3dcd --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,23 @@ +included: + - Sources + +# rule identifiers to exclude from running +disabled_rules: + - cyclomatic_complexity + - large_tuple + - todo + +shorthand_operator: warning + +# configurable rules can be customized from this configuration file +line_length: +- 200 +- 250 + +function_body_length: +- 50 +- 100 + +file_length: +- 500 +- 600 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..d0e7304 --- /dev/null +++ b/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version:5.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "cd2sql", + products: [ + .executable( + name: "cd2sql", targets: ["cd2sql"] + ) + ], + dependencies: [ + .package(url: "https://github.com/phimage/MomXML" , .upToNextMajor(from: "1.1.0")), + .package(url: "https://github.com/apple/swift-argument-parser", from: "0.0.6"), + .package(url: "https://github.com/nvzqz/FileKit.git", from: "6.0.0") + ], + targets: [ + .target( + name: "cd2sql", + dependencies: ["MomXML", "ArgumentParser", "FileKit"], + path: "Sources" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..628edc9 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# CoreData to SQL + +![Swift](https://github.com/phimage/cd2sql/workflows/Swift/badge.svg) +![release](https://github.com/phimage/cd2sql/workflows/release/badge.svg) + +Convert CoreData model to SQL + +## Install + +### Using release + +Go to https://github.com/phimage/cd2sql/releases and take the last binary for macOS cd2sql.zip + +### Using sources + +``` +git clone https://github.com/phimage/cd2sql.git +cd cd2sql +swift build -c release +``` + +Binary result in `.build/release/cd2sql` + +## Usage + +``` +cd2sql +``` + +### example + +``` +cd2sql /path/to/MyModel.xcdatamodeld +``` + +```sql +CREATE TABLE Personne ( + FirstName VARCHAR, + LastName VARCHAR, + ID INTEGER PRIMARY KEY +); +``` diff --git a/Sources/Command.swift b/Sources/Command.swift new file mode 100644 index 0000000..851fe0e --- /dev/null +++ b/Sources/Command.swift @@ -0,0 +1,94 @@ +// +// Command.swift +// +// Created by phimage on 15/05/2020. +// + +import Foundation +import ArgumentParser +import MomXML +import SWXMLHash +import FileKit + +struct Command: ParsableCommand { + + static let configuration = CommandConfiguration(abstract: "Transform core data model to SQL") + + @Option(help: "The core data model path.") + var path: String? + + @Argument(help: "The core data model path.") + var pathArg: String? + + @Option(default: "", help: "Format sush as 'sqlite'") + var format: String? // SQLite + + @Option(default: "keyMapping", help: "Search table and field names inside userinfo using this key") + var mapping: String? + + @Option(help: "userinfo key used to find primary key (default: primaryKey)") + var primaryKey: String? + + // @Flag [IF NOT EXISTS] + + func validate() throws { + guard let path = self.path ?? self.pathArg else { + throw ValidationError("'' of core data model not specified.") + } + guard Path(path).exists else { + throw ValidationError("'' \(path) doesn't not exist.") + } + } + + func run() throws { + var modelURL = URL(fileURLWithPath: self.path ?? self.pathArg ?? "") + if modelURL.pathExtension == "xcdatamodeld" { + modelURL = modelURL.appendingPathComponent("\(modelURL.deletingPathExtension().lastPathComponent).xcdatamodel") + } + if modelURL.pathExtension == "xcdatamodel" { + modelURL = modelURL.appendingPathComponent("contents") + } + + let xmlString = try String(contentsOf: modelURL) + let xml = SWXMLHash.parse(xmlString) + guard let parsedMom = MomXML(xml: xml) else { + error("Failed to parse \(modelURL)") + return + } + let sqliteLite = format == "sqlite" + + for entity in parsedMom.model.entities { + let tableName = entity.name(sqlite: sqliteLite, mapping: mapping) + var sql = "CREATE TABLE \(tableName) (\n" + let primaryKey = entity.userInfo[self.primaryKey ?? "primaryKey"] + + var first = true + for attribute in entity.attributes { + if first { + first = false + } else { + sql += ",\n" + } + let attributeName = attribute.name(sqlite: sqliteLite, mapping: mapping) + sql += " \(attributeName) \(attribute.attributeType.sqliteName)" + if !attribute.isOptional { + sql += " NOT NULL" + } + if attributeName == primaryKey { + sql += " PRIMARY KEY" + } + } + sql += "\n);\n" + log(sql) + } + } + + func log(_ message: String) { + print(message) + } + + func error(_ message: String) { + print("❌ error: \(message)") // TODO: output in stderr + } + +} diff --git a/Sources/Helpers.swift b/Sources/Helpers.swift new file mode 100644 index 0000000..c1ebc22 --- /dev/null +++ b/Sources/Helpers.swift @@ -0,0 +1,67 @@ +import Foundation +import MomXML + +protocol MomType { + var name: String {get} + var sqliteName: String {get} + var userInfo: MomUserInfo {get} +} + +extension MomEntity: MomType { + + var sqliteName: String { + return "Z\(name.uppercased())" + } +} +extension MomAttribute: MomType { + + var sqliteName: String { + return "Z\(name.uppercased())" + } +} + +extension MomType { + func name(sqlite: Bool, mapping: String?) -> String { + if sqlite { + return self.sqliteName + } else { + if let mapping = mapping, let name = self.userInfo[mapping] { + return name + } + return self.name + } + } +} + +extension MomAttribute.AttributeType { + + var sqliteName: String { + switch self { + case .string: + return "VARCHAR" + case .date: + return "TIMESTAMP" + case .integer32: + return "INTEGER" + case .boolean: + return "INTEGER" + case .binary, .transformable: + return "BLOB" + default: + return self.rawValue.uppercased() + } + } + +} + +extension MomUserInfo { + + subscript(key: String) -> String? { + for userInfo in self.entries { + if userInfo.key == key { + return userInfo.value + } + } + return nil + } +} diff --git a/Sources/main.swift b/Sources/main.swift new file mode 100644 index 0000000..42ae464 --- /dev/null +++ b/Sources/main.swift @@ -0,0 +1,2 @@ +// Run command +Command.main()