diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..bed0b9b --- /dev/null +++ b/.clangd @@ -0,0 +1,12 @@ +If: + PathMatch: src/.* + +CompileFlags: + CompilationDatabase: build/release + +--- +If: + PathMatch: test/.* + +CompileFlags: + CompilationDatabase: build/unit_tests diff --git a/docs/pages/index.md b/docs/pages/index.md index 990b66e..d175837 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -116,6 +116,9 @@ COPY TO '11QdEasMWbETbFVxry-SsD8jVcdYIT1zBQszcF84MdE8' (FORMAT gshe -- Write a spreadsheet from a table by full URL COPY TO 'https://docs.google.com/spreadsheets/d/11QdEasMWbETbFVxry-SsD8jVcdYIT1zBQszcF84MdE8/edit?usp=sharing' (FORMAT gsheet); +-- Create a sheet if it doesn't already exist +COPY TO 'https://docs.google.com/spreadsheets/d/11QdEasMWbETbFVxry-SsD8jVcdYIT1zBQszcF84MdE8/edit' (FORMAT gsheet, sheet 'Woot', create_if_not_exists true); + -- Write a spreadsheet to a specific sheet using the sheet id in the URL COPY TO 'https://docs.google.com/spreadsheets/d/11QdEasMWbETbFVxry-SsD8jVcdYIT1zBQszcF84MdE8/edit?gid=1295634987#gid=1295634987' (FORMAT gsheet); diff --git a/src/gsheets_copy.cpp b/src/gsheets_copy.cpp index 7b3472f..eaab03c 100644 --- a/src/gsheets_copy.cpp +++ b/src/gsheets_copy.cpp @@ -1,7 +1,10 @@ -#include +#include +#include "duckdb/common/case_insensitive_map.hpp" #include "duckdb/common/exception.hpp" +#include "duckdb/common/exception/binder_exception.hpp" #include "duckdb/common/file_system.hpp" +#include "duckdb/common/types.hpp" #include "duckdb/common/types/value.hpp" #include "gsheets_copy.hpp" @@ -9,6 +12,7 @@ #include "sheets/auth_factory.hpp" #include "sheets/client.hpp" +#include "sheets/exception.hpp" #include "sheets/range.hpp" #include "sheets/transport/client_factory.hpp" #include "sheets/types.hpp" @@ -22,110 +26,66 @@ GSheetCopyFunction::GSheetCopyFunction() : CopyFunction("gsheet") { copy_to_sink = GSheetWriteSink; } +static std::string GetStringOption(const case_insensitive_map_t> &options, const std::string &name, + const std::string &default_value = "") { + const auto it = options.find(name); + if (it == options.end()) { + return default_value; + } + std::string err; + Value val; + if (!it->second.back().DefaultTryCastAs(LogicalType::VARCHAR, val, &err)) { + throw BinderException(name + " option must be VARCHAR"); + } + if (val.IsNull()) { + throw BinderException(name + " option must not be NULL"); + } + return StringValue::Get(val); +} + +// NOTE: the second value in pair is a flag indicating if the value was set by the user +static std::pair GetBoolOption(const case_insensitive_map_t> &options, + const std::string &name, bool default_value = false) { + const auto it = options.find(name); + if (it == options.end()) { + return std::make_pair(default_value, false); + } + if (it->second.size() != 1) { + throw BinderException(name + " option must be a single boolean value"); + } + std::string err; + Value val; + if (!it->second.back().DefaultTryCastAs(LogicalType::BOOLEAN, val, &err)) { + throw BinderException(name + " option must be a single boolean value"); + } + if (val.IsNull()) { + throw BinderException(name + " option must be a single boolean value"); + } + return std::make_pair(BooleanValue::Get(val), true); +} + unique_ptr GSheetCopyFunction::GSheetWriteBind(ClientContext &context, CopyFunctionBindInput &input, const vector &names, const vector &sql_types) { - string file_path = input.info.file_path; + string file_path = input.info.file_path; auto options = input.info.options; - const auto sheet_opt = options.find("sheet"); - std::string sheet; - if (sheet_opt != options.end()) { - string error_msg; - Value sheet_val; - if (!sheet_opt->second.back().DefaultTryCastAs(LogicalType::VARCHAR, sheet_val, &error_msg)) { - throw BinderException("sheet option must be a VARCHAR"); - } - if (sheet_val.IsNull()) { - throw BinderException("sheet option must be a non-null VARCHAR"); - } - sheet = StringValue::Get(sheet_val); - } else { - sheet = ""; - } + auto sheet = GetStringOption(options, "sheet"); + auto range = GetStringOption(options, "range"); + bool overwrite_sheet = GetBoolOption(options, "overwrite_sheet", true).first; + bool overwrite_range = GetBoolOption(options, "overwrite_range", false).first; + bool create_if_not_exists = GetBoolOption(options, "create_if_not_exists", false).first; - const auto range_opt = options.find("range"); - std::string range; - if (range_opt != options.end()) { - string error_msg; - Value range_val; - if (!range_opt->second.back().DefaultTryCastAs(LogicalType::VARCHAR, range_val, &error_msg)) { - throw BinderException("range option must be a VARCHAR"); - } - if (range_val.IsNull()) { - throw BinderException("range option must be a non-null VARCHAR"); - } - range = StringValue::Get(range_val); - } else { - range = ""; - } + auto header_result = GetBoolOption(options, "header", true); + bool header = header_result.second ? header_result.first : (overwrite_range || overwrite_sheet); - const auto overwrite_sheet_opt = options.find("overwrite_sheet"); - bool overwrite_sheet; - if (overwrite_sheet_opt != options.end()) { - if (overwrite_sheet_opt->second.size() != 1) { - throw BinderException("overwrite_sheet option must be a single boolean value"); - } - string error_msg; - Value overwrite_sheet_bool_val; - if (!overwrite_sheet_opt->second.back().DefaultTryCastAs(LogicalType::BOOLEAN, overwrite_sheet_bool_val, - &error_msg)) { - throw BinderException("overwrite_sheet option must be a single boolean value"); - } - if (overwrite_sheet_bool_val.IsNull()) { - throw BinderException("overwrite_sheet option must be a single boolean value"); - } - overwrite_sheet = BooleanValue::Get(overwrite_sheet_bool_val); - } else { - overwrite_sheet = true; // Default to overwrite_sheet to maintain prior behavior - } - - const auto overwrite_range_opt = options.find("overwrite_range"); - bool overwrite_range; - if (overwrite_range_opt != options.end()) { - if (overwrite_range_opt->second.size() != 1) { - throw BinderException("overwrite_range option must be a single boolean value"); - } - string error_msg; - Value overwrite_range_bool_val; - if (!overwrite_range_opt->second.back().DefaultTryCastAs(LogicalType::BOOLEAN, overwrite_range_bool_val, - &error_msg)) { - throw BinderException("overwrite_range option must be a single boolean value"); - } - if (overwrite_range_bool_val.IsNull()) { - throw BinderException("overwrite_range option must be a single boolean value"); - } - overwrite_range = BooleanValue::Get(overwrite_range_bool_val); - } else { - overwrite_range = false; - } - - const auto header_opt = options.find("header"); - bool header; - if (header_opt != options.end()) { - if (header_opt->second.size() != 1) { - throw BinderException("header option must be a single boolean value"); - } - string error_msg; - Value header_bool_val; - if (!header_opt->second.back().DefaultTryCastAs(LogicalType::BOOLEAN, header_bool_val, &error_msg)) { - throw BinderException("header option must be a single boolean value"); - } - if (header_bool_val.IsNull()) { - throw BinderException("header option must be a single boolean value"); - } - header = BooleanValue::Get(header_bool_val); - } else { - header = true; - // If we are in the append case, default to no header instead. - if (!overwrite_sheet && !overwrite_range) { - header = false; - } + if (create_if_not_exists && sheet.empty()) { + throw BinderException("Must provide sheet name"); } return make_uniq(file_path, sql_types, names, sheet, range, overwrite_sheet, overwrite_range, - header); + create_if_not_exists, header); } unique_ptr GSheetCopyFunction::GSheetWriteInitializeGlobal(ClientContext &context, @@ -154,6 +114,16 @@ unique_ptr GSheetCopyFunction::GSheetWriteInitializeGlobal(C sheet_name = sheet.properties.title; } + // Create sheet if not exist (if enabled) + if (options.create_if_not_exists) { + try { + auto sheet = client.Spreadsheets(spreadsheet_id).GetSheetByName(sheet_name); + // sheet already exists so we need to ignore this instruction from the user + } catch (sheets::SheetNotFoundException &_) { + client.Spreadsheets(spreadsheet_id).CreateSheet(sheet_name); + } + } + if (!options.range.empty()) { sheet_range = options.range; } else { diff --git a/src/include/gsheets_copy.hpp b/src/include/gsheets_copy.hpp index 91bafe4..f72d8e8 100644 --- a/src/include/gsheets_copy.hpp +++ b/src/include/gsheets_copy.hpp @@ -29,6 +29,7 @@ struct GSheetWriteOptions { std::string range; bool overwrite_sheet; bool overwrite_range; + bool create_if_not_exists; bool header; }; @@ -38,7 +39,8 @@ struct GSheetWriteBindData : public TableFunctionData { vector sql_types; GSheetWriteBindData(string file_path, vector sql_types, vector names, std::string sheet, - std::string range, bool overwrite_sheet, bool overwrite_range, bool header) + std::string range, bool overwrite_sheet, bool overwrite_range, bool create_if_not_exists, + bool header) : sql_types(std::move(sql_types)) { files.push_back(std::move(file_path)); options.name_list = std::move(names); @@ -46,6 +48,7 @@ struct GSheetWriteBindData : public TableFunctionData { options.range = std::move(range); options.overwrite_sheet = overwrite_sheet; options.overwrite_range = overwrite_range; + options.create_if_not_exists = create_if_not_exists; options.header = header; } }; diff --git a/src/include/sheets/exception.hpp b/src/include/sheets/exception.hpp index 594935c..d80498b 100644 --- a/src/include/sheets/exception.hpp +++ b/src/include/sheets/exception.hpp @@ -16,8 +16,7 @@ class SheetsApiException : public SheetsException { public: SheetsApiException(int statusCode, const std::string &apiMessage) : SheetsException("Google Sheets API error (" + std::to_string(statusCode) + "): " + apiMessage), - statusCode(statusCode), - apiMessage(apiMessage) { + statusCode(statusCode), apiMessage(apiMessage) { } int GetStatusCode() const { @@ -53,5 +52,11 @@ class SheetNotFoundException : public SheetsException { std::string identifier; }; +class SheetNotCreatedException : public SheetsException { +public: + explicit SheetNotCreatedException(const std::string &name) : SheetsException("Sheet not created: " + name) { + } +}; + } // namespace sheets } // namespace duckdb diff --git a/src/include/sheets/resources/spreadsheet.hpp b/src/include/sheets/resources/spreadsheet.hpp index badee80..9ec064b 100644 --- a/src/include/sheets/resources/spreadsheet.hpp +++ b/src/include/sheets/resources/spreadsheet.hpp @@ -15,15 +15,19 @@ class SpreadsheetResource : protected BaseResource { SpreadsheetMetadata Get(); - SheetMetadata GetSheetById(int sheetId); + SheetMetadata GetSheetById(const int sheetId); SheetMetadata GetSheetById(const std::string &sheetId); SheetMetadata GetSheetByName(const std::string &name); - SheetMetadata GetSheetByIndex(int index); + SheetMetadata GetSheetByIndex(const int index); + + SheetMetadata CreateSheet(const std::string &name); ValuesResource Values(); private: std::string spreadsheetId; + + SpreadsheetBatchUpdateResponse BatchUpdate(const SpreadsheetBatchUpdateRequest &req); }; } // namespace sheets diff --git a/src/include/sheets/types.hpp b/src/include/sheets/types.hpp index 4499b7b..9d3471a 100644 --- a/src/include/sheets/types.hpp +++ b/src/include/sheets/types.hpp @@ -48,6 +48,42 @@ struct SpreadsheetMetadata { NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(SpreadsheetMetadata, spreadsheetId, properties, sheets) +struct AddSheetRequestProperties { + std::string title = ""; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(AddSheetRequestProperties, title) + +struct AddSheetRequest { + AddSheetRequestProperties properties = {}; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(AddSheetRequest, properties) + +struct SpreadsheetUpdateRequest { + AddSheetRequest addSheet = {}; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(SpreadsheetUpdateRequest, addSheet); + +struct SpreadsheetUpdateResponse { + SheetMetadata addSheet = {}; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(SpreadsheetUpdateResponse, addSheet); + +struct SpreadsheetBatchUpdateRequest { + std::vector requests = {}; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(SpreadsheetBatchUpdateRequest, requests); + +struct SpreadsheetBatchUpdateResponse { + std::vector replies = {}; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(SpreadsheetBatchUpdateResponse, replies); + enum MajorDimension { DIMENSION_UNSPECIFIED, ROWS, COLUMNS }; NLOHMANN_JSON_SERIALIZE_ENUM(MajorDimension, { diff --git a/src/sheets/resources/spreadsheet.cpp b/src/sheets/resources/spreadsheet.cpp index 3da95cb..0ad0866 100644 --- a/src/sheets/resources/spreadsheet.cpp +++ b/src/sheets/resources/spreadsheet.cpp @@ -1,10 +1,15 @@ #include +#include "json.hpp" + #include "sheets/resources/spreadsheet.hpp" #include "sheets/exception.hpp" +#include "sheets/resources/values.hpp" #include "sheets/types.hpp" #include "sheets/util/response.hpp" +using json = nlohmann::json; + namespace duckdb { namespace sheets { @@ -13,7 +18,7 @@ SpreadsheetMetadata SpreadsheetResource::Get() { return ParseResponse(DoGet(path)); } -SheetMetadata SpreadsheetResource::GetSheetById(int sheetId) { +SheetMetadata SpreadsheetResource::GetSheetById(const int sheetId) { auto meta = Get(); for (const auto &sheet : meta.sheets) { if (sheet.properties.sheetId == sheetId) { @@ -38,7 +43,7 @@ SheetMetadata SpreadsheetResource::GetSheetByName(const std::string &name) { throw SheetNotFoundException(name); } -SheetMetadata SpreadsheetResource::GetSheetByIndex(int index) { +SheetMetadata SpreadsheetResource::GetSheetByIndex(const int index) { auto meta = Get(); for (const auto &sheet : meta.sheets) { if (sheet.properties.index == index) { @@ -48,6 +53,27 @@ SheetMetadata SpreadsheetResource::GetSheetByIndex(int index) { throw SheetNotFoundException(std::to_string(index)); } +SheetMetadata SpreadsheetResource::CreateSheet(const std::string &name) { + SpreadsheetUpdateRequest update; + update.addSheet.properties.title = name; + + SpreadsheetBatchUpdateRequest req; + req.requests.push_back(update); + + SpreadsheetBatchUpdateResponse res = BatchUpdate(req); + if (res.replies.empty()) { + throw SheetNotCreatedException(name); + } + auto reply = res.replies.front(); + return reply.addSheet; +} + +SpreadsheetBatchUpdateResponse SpreadsheetResource::BatchUpdate(const SpreadsheetBatchUpdateRequest &req) { + std::string path = "/spreadsheets/" + spreadsheetId + ":batchUpdate"; + std::string body = json(req).dump(); + return ParseResponse(DoPost(path, body)); +} + ValuesResource SpreadsheetResource::Values() { return ValuesResource(http, headers, baseUrl, spreadsheetId); } diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index c223427..c768911 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -1,3 +1,5 @@ +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + cmake_minimum_required(VERSION 3.5) project(gsheets_unit_tests) diff --git a/test/unit/sheets/resources/test_spreadsheet.cpp b/test/unit/sheets/resources/test_spreadsheet.cpp index a37b2bd..f2bee0a 100644 --- a/test/unit/sheets/resources/test_spreadsheet.cpp +++ b/test/unit/sheets/resources/test_spreadsheet.cpp @@ -118,7 +118,7 @@ TEST_CASE("SpreadsheetResource::Values returns working ValuesResource", "[spread // ============================================================================= // Helper: common spreadsheet response with multiple sheets -static const char *MULTI_SHEET_RESPONSE = R"({ +static const char *const MULTI_SHEET_RESPONSE = R"({ "spreadsheetId": "abc123", "properties": {"title": "Test Spreadsheet"}, "sheets": [ @@ -205,3 +205,23 @@ TEST_CASE("SpreadsheetResource::GetSheetByIndex throws when not found", "[spread REQUIRE_THROWS_AS(spreadsheet.GetSheetByIndex(100), duckdb::sheets::SheetNotFoundException); } + +// ============================================================================= +// SpreadsheetResource::GetSheetByIndex Tests +// ============================================================================= + +TEST_CASE("SpreadsheetResource::CreateSheet returns new sheet", "[spreadsheet]") { + duckdb::sheets::MockHttpClient mockHttp; + mockHttp.AddResponse({200, {}, R"({ + "replies": [ + {"addSheet": {"properties": {"title": "test1"}}} + ] + })"}); + + duckdb::sheets::HttpHeaders headers; + duckdb::sheets::SpreadsheetResource spreadsheet(mockHttp, headers, "https://sheets.googleapis.com/v4", "abc123"); + + auto sheet = spreadsheet.CreateSheet("test1"); + + REQUIRE(sheet.properties.title == "test1"); +}