diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cf0d280..3ba4731 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,11 +2,6 @@ name: Docs on: push: branches: ["main"] - paths: - - 'Sources/**' - - '.github/workflows/docs.yml' - - 'Package.swift' - - 'Makefile' workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages @@ -30,14 +25,14 @@ jobs: with: fetch-depth: 0 - name: Run build docs - run: make Build/Docs + run: make build/docs - name: Setup GitHub Pages id: pages uses: actions/configure-pages@v5 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: Build/Docs + path: build/docs deploy: runs-on: ubuntu-latest needs: build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a353839..9b9e6ea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,21 +5,9 @@ on: - main tags-ignore: - '**' - paths: - - 'Sources/**' - - 'Tests/**' - - '.github/workflows/test.yml' - - 'Package.swift' - - 'Makefile' pull_request: branches: - '**' - paths: - - 'Sources/**' - - 'Tests/**' - - '.github/workflows/**' - - 'Package.swift' - - 'Makefile' jobs: Apple: @@ -30,9 +18,9 @@ jobs: matrix: include: - name: macOS - target: test-macos + target: build/test-macos.xcresult - name: iOS - target: test-ios + target: build/test-ios.xcresult steps: - name: Checkout uses: actions/checkout@v4 @@ -48,6 +36,15 @@ jobs: - name: Build and test run: swift test shell: bash + Snippets: + name: Run snippets + runs-on: macos-26 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Run snippets + run: make run-snippets + shell: bash Linux: name: Test Linux runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 3f63484..fb96834 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ xcuserdata/ /.build # Makefile output dir -/Build +/build diff --git a/Makefile b/Makefile index 73cbad4..d725451 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,12 @@ -TARGET_NAME = SQLyra -OUTPUD_DIR = ./Build -DERIVED_DATA_PATH = $(OUTPUD_DIR)/DerivedData +TARGET = SQLyra +SNIPPETS := $(notdir $(basename $(wildcard Snippets/*.swift))) -.PHONY: clean lint format test test-macos test-ios test-linux +.PHONY: clean lint format test-linux $(SNIPPETS) test-snippets learn preview-doc clean: swift package clean - rm -rf $(OUTPUD_DIR) + rm -rf ./build + rm -rf ./Snippets/db.sqlite README.md: Playgrounds/README.playground/Contents.swift cat $< | ./Scripts/markdown.swift > $@ @@ -19,60 +19,54 @@ lint: format: xcrun swift-format --recursive --in-place ./ -# MARK: - Tests +# MARK: - Apple Tests -test: - swift test +XCODEBUILD_TEST = xcodebuild test \ + -quiet \ + -scheme $(TARGET) \ + -resultBundlePath $@ -test-macos: $(OUTPUD_DIR)/test-macos.xcresult -test-ios: $(OUTPUD_DIR)/test-ios.xcresult +XCCOV = xcrun xccov view --files-for-target $(TARGET) --report $@ -XCODEBUILD_TEST = xcodebuild test -quiet -scheme $(TARGET_NAME) -resultBundlePath $@ -XCCOV = xcrun xccov view --files-for-target $(TARGET_NAME) --report $@ - -$(OUTPUD_DIR)/test-macos.xcresult: +build/test-macos.xcresult: $(XCODEBUILD_TEST) -destination 'platform=macOS' $(XCCOV) -$(OUTPUD_DIR)/test-ios.xcresult: +build/test-ios.xcresult: $(XCODEBUILD_TEST) -destination 'platform=iOS Simulator,name=iPhone 17' $(XCCOV) -# Apple Containerization or Docker +# MARK: - Linux Tests + +# Apple Container or Docker CONTAINER ?= container test-linux: $(CONTAINER) run --rm -v "$(PWD):/src" -w /src swift:latest /bin/bash -c \ "apt-get update && apt-get install -y libsqlite3-dev && swift test" -# MARK: - DocC - -DOCC_ARCHIVE = $(DERIVED_DATA_PATH)/Build/Products/Debug/$(TARGET_NAME).doccarchive +# MARK: - Snippets -$(DOCC_ARCHIVE): - xcodebuild docbuild \ - -quiet \ - -scheme $(TARGET_NAME) \ - -destination "generic/platform=macOS" \ - -derivedDataPath $(DERIVED_DATA_PATH) +$(SNIPPETS): + swift run --quiet $@ -$(OUTPUD_DIR)/Docs: $(DOCC_ARCHIVE) - xcrun docc process-archive transform-for-static-hosting $^ \ - --hosting-base-path $(TARGET_NAME) \ - --output-path $@ - -# MARK: - DocC preview +run-snippets: $(SNIPPETS) -DOC_CATALOG = Sources/$(TARGET_NAME)/$(TARGET_NAME).docc -SYMBOL_GRAPHS = $(OUTPUD_DIR)/symbol-graphs +learn: + SWIFTPM_ENABLE_SNIPPETS=1 swift package learn -$(SYMBOL_GRAPHS): - swift build --target $(TARGET_NAME) -Xswiftc -emit-symbol-graph -Xswiftc -emit-symbol-graph-dir -Xswiftc $@ +# MARK: - DocC -$(OUTPUD_DIR)/doc-preview: $(DOC_CATALOG) $(SYMBOL_GRAPHS) - xcrun docc preview $(DOC_CATALOG) \ - --fallback-display-name $(TARGET_NAME) \ - --fallback-bundle-identifier org.swift.$(TARGET_NAME) \ - --fallback-bundle-version 1.0.0 \ - --additional-symbol-graph-dir $(SYMBOL_GRAPHS) \ +build/docs: + env SQLYRA_DOCС_PLUGIN=1 \ + swift package --allow-writing-to-directory $@ \ + generate-documentation \ + --target $(TARGET) \ + --transform-for-static-hosting \ + --hosting-base-path $(TARGET) \ --output-path $@ + +preview-doc: + env SQLYRA_DOCС_PLUGIN=1 \ + swift package --disable-sandbox \ + preview-documentation --target $(TARGET) diff --git a/Package.swift b/Package.swift index 1aeb2af..f3f0068 100644 --- a/Package.swift +++ b/Package.swift @@ -43,3 +43,7 @@ let package = Package( ), ] ) + +if Context.environment["SQLYRA_DOCС_PLUGIN"] == "1" { + package.dependencies.append(.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0")) +} diff --git a/Snippets/GettingStarted.swift b/Snippets/GettingStarted.swift new file mode 100644 index 0000000..4ae0ee1 --- /dev/null +++ b/Snippets/GettingStarted.swift @@ -0,0 +1,68 @@ +// snippet.hide +import Foundation +// snippet.show +import SQLyra + +// snippet.hide +struct WorkingDirectory: ~Copyable { + private let fileManager = FileManager.default + + deinit { + try? removeDatabase() + } + + func prepare() throws { + if !fileManager.currentDirectoryPath.hasSuffix("Snippets") { + precondition(fileManager.changeCurrentDirectoryPath("Snippets/"), "couldn't change directory") + } + print("currentDirectoryPath:", fileManager.currentDirectoryPath) + try removeDatabase() + } + + func removeDatabase() throws { + try removeFile(path: "db.sqlite") + } + + func removeFile(path: String) throws { + if fileManager.fileExists(atPath: path) { + try fileManager.removeItem(atPath: path) + } + } +} + +let workingDirectory = WorkingDirectory() +try workingDirectory.prepare() + +// snippet.show +let database = try Database.open( + at: "db.sqlite", + options: [.create, .readwrite] +) + +let schema = """ + CREATE TABLE IF NOT EXISTS contacts( + id INT PRIMARY KEY NOT NULL, + name TEXT + ); + """ +try database.execute(schema) + +let insert = try database.prepare( + "INSERT INTO contacts (id, name) VALUES (?, ?);" +) +try insert.bind(parameters: 1, "Paul") +try insert.execute() +try insert.bind(parameters: 2, "John") +try insert.execute() + +struct Contact: Codable { + let id: Int + let name: String? +} + +let contacts = try database.prepare("SELECT * FROM contacts;").array(Contact.self) +print(contacts) +// [GettingStarted.Contact(id: 1, name: Optional("Paul")), GettingStarted.Contact(id: 2, name: Optional("John"))] + +// snippet.hide +try workingDirectory.removeDatabase() diff --git a/Snippets/RetrievingStatementSQL.swift b/Snippets/RetrievingStatementSQL.swift new file mode 100644 index 0000000..461c24f --- /dev/null +++ b/Snippets/RetrievingStatementSQL.swift @@ -0,0 +1,18 @@ +// snippet.hide +#if !os(Linux) + +import SQLyra + +let db = try Database.open(at: ":memory:", options: [.memory, .readwrite]) +try db.execute("CREATE TABLE IF NOT EXISTS contacts (id INT, name TEXT);") + +// snippet.show +let statement = try db.prepare("INSERT INTO contacts (id, name) VALUES (?, ?);") +try statement.bind(parameters: 1, "Paul") + +assert(statement.sql == "INSERT INTO contacts (id, name) VALUES (?, ?);") +assert(statement.expandedSQL == "INSERT INTO contacts (id, name) VALUES (1, 'Paul');") +assert(statement.normalizedSQL == "INSERT INTO contacts(id,name)VALUES(?,?);") + +// snippet.hide +#endif diff --git a/Snippets/SQLParameters.swift b/Snippets/SQLParameters.swift new file mode 100644 index 0000000..6db3ca6 --- /dev/null +++ b/Snippets/SQLParameters.swift @@ -0,0 +1,14 @@ +// snippet.hide +import SQLyra + +let db = try Database.open(at: ":memory:", options: [.memory, .readwrite]) +try db.execute("CREATE TABLE users (id INT, email TEXT);") + +// snippet.show +let statement = try db.prepare("INSERT INTO users (id, email) VALUES (?, :login)") + +assert(statement.parameterCount == 2) +assert(statement.parameterName(at: 1) == nil) +assert(statement.parameterName(at: 2) == ":login") +assert(statement.parameterIndex(for: ":id") == 0) // invalid +assert(statement.parameterIndex(for: ":login") == 2) diff --git a/Sources/SQLyra/Database.swift b/Sources/SQLyra/Database.swift index 64f0e3c..ef6d2c0 100644 --- a/Sources/SQLyra/Database.swift +++ b/Sources/SQLyra/Database.swift @@ -1,6 +1,8 @@ import SQLite3 /// SQLite database. +/// +/// @Snippet(path: "SQLyra/Snippets/GettingStarted") public final class Database { /// Database open options. public struct OpenOptions: OptionSet, Sendable { diff --git a/Sources/SQLyra/PreparedStatement.swift b/Sources/SQLyra/PreparedStatement.swift index bb2218b..39fd8dc 100644 --- a/Sources/SQLyra/PreparedStatement.swift +++ b/Sources/SQLyra/PreparedStatement.swift @@ -10,6 +10,8 @@ import FoundationEssentials /// /// To execute an SQL statement, it must first be compiled into a byte-code program using one of these routines. /// Or, in other words, these routines are constructors for the prepared statement object. +/// +/// [Prepared Statement Object](https://www.sqlite.org/c3ref/stmt.html) public final class PreparedStatement { let stmt: OpaquePointer let database: Database // release database after all statements @@ -66,6 +68,9 @@ public final class PreparedStatement { extension PreparedStatement { /// SQL text used to create prepared statement. + /// + /// [Retrieving statement SQL](https://www.sqlite.org/c3ref/expanded_sql.html) + /// @Snippet(path: "SQLyra/Snippets/RetrievingStatementSQL") public var sql: String { sqlite3_sql(stmt).string ?? "" } #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) @@ -73,11 +78,17 @@ extension PreparedStatement { /// /// The semantics used to normalize a SQL statement are unspecified and subject to change. /// At a minimum, literal values will be replaced with suitable placeholders. + /// + /// [Retrieving statement SQL](https://www.sqlite.org/c3ref/expanded_sql.html) + /// @Snippet(path: "SQLyra/Snippets/RetrievingStatementSQL") @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) public var normalizedSQL: String { sqlite3_normalized_sql(stmt).string ?? "" } #endif /// SQL text of prepared statement with bound parameters expanded. + /// + /// [Retrieving statement SQL](https://www.sqlite.org/c3ref/expanded_sql.html) + /// @Snippet(path: "SQLyra/Snippets/RetrievingStatementSQL") public var expandedSQL: String { guard let pointer = sqlite3_expanded_sql(stmt) else { return "" } defer { sqlite3_free(pointer) } @@ -89,14 +100,23 @@ extension PreparedStatement { extension PreparedStatement { /// Number of SQL parameters. + /// + /// [Number Of SQL Parameters](https://www.sqlite.org/c3ref/bind_parameter_count.html). + /// @Snippet(path: "SQLyra/Snippets/SQLParameters") public var parameterCount: Int { Int(sqlite3_bind_parameter_count(stmt)) } /// Name of a SQL parameter. + /// + /// [Name of a SQL parameter](https://www.sqlite.org/c3ref/bind_parameter_name.html). + /// @Snippet(path: "SQLyra/Snippets/SQLParameters") public func parameterName(at index: Int) -> String? { sqlite3_bind_parameter_name(stmt, Int32(index)).map { String(cString: $0) } } - /// Index of a parameter with a given name. + /// Index of a parameter with a given name + /// + /// [Index of a parameter with a given name](https://www.sqlite.org/c3ref/bind_parameter_index.html). + /// @Snippet(path: "SQLyra/Snippets/SQLParameters") public func parameterIndex(for name: String) -> Int { Int(sqlite3_bind_parameter_index(stmt, name)) } diff --git a/Sources/SQLyra/SQLyra.docc/PreparedStatement.md b/Sources/SQLyra/SQLyra.docc/PreparedStatement.md new file mode 100644 index 0000000..b9fd9ae --- /dev/null +++ b/Sources/SQLyra/SQLyra.docc/PreparedStatement.md @@ -0,0 +1,41 @@ +# ``SQLyra/PreparedStatement`` + +## Topics + +### Execution + +- ``PreparedStatement/execute()`` +- ``PreparedStatement/reset()`` + +### Retrieving Statement SQL + +- ``PreparedStatement/sql`` +- ``PreparedStatement/normalizedSQL`` +- ``PreparedStatement/expandedSQL`` + +### SQL Parameters + +- ``PreparedStatement/parameterCount`` +- ``PreparedStatement/parameterName(at:)`` +- ``PreparedStatement/parameterIndex(for:)`` + +### Binding parameters + +- ``PreparedStatement/clearBindings()`` +- ``PreparedStatement/bind(parameters:)`` +- ``PreparedStatement/bind(name:parameter:)`` +- ``PreparedStatement/bind(index:parameter:)`` +- ``SQLParameter`` + +### Columns + +- ``PreparedStatement/columnCount`` +- ``PreparedStatement/columnName(at:)`` + +### Result values from a Query + +- ``PreparedStatement/row()`` +- ``PreparedStatement/array(_:)`` +- ``PreparedStatement/array(_:using:)`` +- ``PreparedStatement/Row`` +- ``PreparedStatement/Value``