diff --git a/README.md b/README.md index 98b518b..1ef0981 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,27 @@ gleam add pog # for PostgreSQL with native runtime gleam add sqlight # for SQLite with native runtime ``` +### Self-contained generation (`vendor_runtime`) + +Setting `gen.gleam.vendor_runtime: true` asks sqlode to copy the +`sqlode/runtime` module into the output directory as `runtime.gleam` +and rewrite the generated imports to point at the local copy. The +generated package no longer needs sqlode as a runtime dependency, +only as a dev dependency (the tool you invoke with `sqlode generate`). +Native adapters still need their driver package (`pog` / `sqlight`). + +```yaml +gen: + gleam: + out: "src/db" + runtime: "raw" + vendor_runtime: true +``` + +Trade-offs: shared-runtime code is smaller and auto-updates with +`gleam update sqlode`; vendored code is self-contained at the cost of +re-running `sqlode generate` to pick up runtime changes. + ## Adapter generation When `runtime` is set to `native`, sqlode generates adapter modules that wrap [pog](https://hexdocs.pm/pog/) (PostgreSQL) or [sqlight](https://hexdocs.pm/sqlight/) (SQLite). @@ -588,7 +609,7 @@ sqlode follows sqlc conventions, so most SQL files work without changes. Key dif | Init | `sqlc init` | `sqlode init` | | Vet/Verify | `sqlc vet`, `sqlc verify` | Not supported | | Target language | Go, Python, Kotlin, etc. | Gleam | -| Runtime | Generated code is self-contained | Generated code imports `sqlode/runtime` | +| Runtime | Generated code is self-contained | Generated code imports `sqlode/runtime` by default; set `vendor_runtime: true` to vendor a copy and drop the runtime dependency (see [Self-contained generation](#self-contained-generation-vendor_runtime)) | ### Migration steps diff --git a/integration_test/cases.sh b/integration_test/cases.sh index a073cf5..2b2d8e2 100644 --- a/integration_test/cases.sh +++ b/integration_test/cases.sh @@ -59,6 +59,59 @@ case_compile_all_types() { expected_files="params.gleam queries.gleam models.gleam" } +# Verify the self-contained generation mode produces a project that +# builds with no sqlode dependency. The generated gleam.toml created +# below deliberately does NOT depend on sqlode (unlike every other +# case), and the vendored runtime.gleam in src/db/ must provide +# everything the other generated modules need. +case_compile_vendor_runtime() { + _icv_label="raw mode with vendor_runtime" + _icv_dir="$INTEGRATION_TMP_BASE/integration_compile_vendor_runtime" + + echo "" + echo "--- $_icv_label ---" + + integration_clean "$_icv_dir" + mkdir -p "$_icv_dir/src/db" + + cat > "$_icv_dir/gleam.toml" < "$_icv_dir/sqlode.yaml" <&2 + return 1 + fi + done + + echo "Building generated project (no sqlode dependency)..." + integration_build "$_icv_dir" + + echo "PASS: $_icv_label" +} + case_sqlite_basic() { run_integration_case \ label="SQLite real database" \ @@ -135,6 +188,7 @@ ALL_INTEGRATION_CASES=" case_compile_raw case_compile_complex case_compile_all_types + case_compile_vendor_runtime case_sqlite_basic case_sqlite_extended case_sqlite_advanced diff --git a/src/sqlode/codegen/adapter.gleam b/src/sqlode/codegen/adapter.gleam index c7923ce..acf6f77 100644 --- a/src/sqlode/codegen/adapter.gleam +++ b/src/sqlode/codegen/adapter.gleam @@ -157,7 +157,7 @@ fn render_adapter( // Every generated adapter now calls `runtime.expand_slice_placeholders` // to substitute the placeholder markers emitted by the query parser, // so the import is always required (not only when slices are used). - ["import sqlode/runtime"], + ["import " <> common.runtime_import_path(gleam)], case has_results || has_enums { True -> ["import " <> module_path <> "/models"] False -> [] diff --git a/src/sqlode/codegen/common.gleam b/src/sqlode/codegen/common.gleam index c3f2236..19aee9f 100644 --- a/src/sqlode/codegen/common.gleam +++ b/src/sqlode/codegen/common.gleam @@ -121,6 +121,18 @@ pub fn out_to_module_path(out: String) -> String { } } +/// Import path used by generated modules for the sqlode runtime. When +/// `gleam.vendor_runtime` is enabled, the runtime source is written +/// into `/runtime.gleam` and the generated code points at that +/// local copy (e.g. `db/runtime`). Otherwise the shared +/// `sqlode/runtime` dependency is used. +pub fn runtime_import_path(gleam: model.GleamOutput) -> String { + case gleam.vendor_runtime { + True -> out_to_module_path(gleam.out) <> "/runtime" + False -> "sqlode/runtime" + } +} + /// Render a single-constructor Gleam type declaration. /// /// gleam_type("UserId", "Int") → diff --git a/src/sqlode/codegen/params.gleam b/src/sqlode/codegen/params.gleam index cecbb48..cf1b0dd 100644 --- a/src/sqlode/codegen/params.gleam +++ b/src/sqlode/codegen/params.gleam @@ -11,6 +11,7 @@ pub fn render( queries: List(model.AnalyzedQuery), type_mapping: model.TypeMapping, module_path: String, + runtime_import: String, ) -> String { let has_slices = common.queries_have_slices(queries) @@ -28,6 +29,8 @@ pub fn render( list.any(q.params, fn(p) { type_mapping.is_rich_type(p.scalar_type) }) }) + let runtime_import_line = "import " <> runtime_import <> ".{type Value}" + let imports = case needs_option_import(queries) { True -> list.flatten([ @@ -35,10 +38,7 @@ pub fn render( True -> ["import gleam/list"] False -> [] }, - [ - "import gleam/option.{type Option, None, Some}", - "import sqlode/runtime.{type Value}", - ], + ["import gleam/option.{type Option, None, Some}", runtime_import_line], case has_enums || needs_models_for_strong { True -> ["import " <> module_path <> "/models"] False -> [] @@ -51,7 +51,7 @@ pub fn render( True -> ["import gleam/list"] False -> [] }, - ["import sqlode/runtime.{type Value}"], + [runtime_import_line], case has_enums || needs_models_for_strong { True -> ["import " <> module_path <> "/models"] False -> [] diff --git a/src/sqlode/codegen/queries.gleam b/src/sqlode/codegen/queries.gleam index dee1306..fba9c80 100644 --- a/src/sqlode/codegen/queries.gleam +++ b/src/sqlode/codegen/queries.gleam @@ -46,7 +46,7 @@ pub fn render( True -> ["import gleam/list"] False -> [] }, - ["import sqlode/runtime"], + ["import " <> common.runtime_import_path(gleam)], case has_any_params { True -> ["import " <> module_path <> "/params"] False -> [] diff --git a/src/sqlode/config.gleam b/src/sqlode/config.gleam index 27a693b..4def643 100644 --- a/src/sqlode/config.gleam +++ b/src/sqlode/config.gleam @@ -119,7 +119,7 @@ fn parse_sql_block(node: yay.Node) -> Result(model.SqlBlock, ConfigError) { gleam_node, [ "out", "runtime", "type_mapping", "emit_sql_as_comment", - "emit_exact_table_names", "omit_unused_models", + "emit_exact_table_names", "omit_unused_models", "vendor_runtime", ], "sql.gen.gleam.", )) @@ -163,6 +163,9 @@ fn parse_sql_block(node: yay.Node) -> Result(model.SqlBlock, ConfigError) { let omit_unused_models = optional_bool(gleam_node, "omit_unused_models") |> option.unwrap(False) + let vendor_runtime = + optional_bool(gleam_node, "vendor_runtime") + |> option.unwrap(False) use overrides <- result.try(parse_overrides(node)) @@ -178,6 +181,7 @@ fn parse_sql_block(node: yay.Node) -> Result(model.SqlBlock, ConfigError) { emit_sql_as_comment:, emit_exact_table_names:, omit_unused_models:, + vendor_runtime:, ), overrides:, )) diff --git a/src/sqlode/generate.gleam b/src/sqlode/generate.gleam index 0b94d19..57687c0 100644 --- a/src/sqlode/generate.gleam +++ b/src/sqlode/generate.gleam @@ -213,6 +213,7 @@ fn base_output_files( analyzed, gleam.type_mapping, common.out_to_module_path(out), + common.runtime_import_path(gleam), ), ), writer.GeneratedFile( @@ -222,7 +223,7 @@ fn base_output_files( ), ] - case has_models { + let files_with_models = case has_models { True -> { let effective_catalog = case gleam.omit_unused_models { True -> prune_catalog_to_used(catalog, analyzed) @@ -245,6 +246,43 @@ fn base_output_files( } False -> files } + + case gleam.vendor_runtime { + True -> + case read_runtime_source() { + Ok(source) -> + list.append(files_with_models, [ + writer.GeneratedFile( + directory: out, + path: "runtime.gleam", + content: source, + ), + ]) + Error(_) -> files_with_models + } + False -> files_with_models + } +} + +/// Return the `sqlode/runtime` module source. Tries known paths in +/// order: a development checkout (when sqlode itself runs the +/// generator), then the extracted hex package layout a user project +/// would have after `gleam add sqlode`. When none are found, emit +/// nothing rather than a broken file — the flag still defaults off, +/// so a user who opted in will at least notice that the file they +/// asked for is missing. +fn read_runtime_source() -> Result(String, Nil) { + let candidates = [ + "src/sqlode/runtime.gleam", + "build/packages/sqlode/src/sqlode/runtime.gleam", + "build/dev/erlang/sqlode/src/sqlode/runtime.gleam", + ] + list.find_map(candidates, fn(path) { + case simplifile.read(path) { + Ok(content) -> Ok(content) + Error(_) -> Error(Nil) + } + }) } /// Drop tables and enums from the catalog that no generated query diff --git a/src/sqlode/model.gleam b/src/sqlode/model.gleam index 2b23917..5401d3c 100644 --- a/src/sqlode/model.gleam +++ b/src/sqlode/model.gleam @@ -104,6 +104,12 @@ pub type GleamOutput { /// tables / query columns / query params). Defaults to False to /// preserve the existing full-catalog output. omit_unused_models: Bool, + /// When True, emit a copy of `sqlode/runtime` into the output + /// directory as `runtime.gleam` and rewrite the generated module + /// imports to point at the local copy. The target project no + /// longer needs `sqlode` as a runtime dependency. Defaults to + /// False so the shared-runtime path stays the default. + vendor_runtime: Bool, ) } diff --git a/test/codegen_test.gleam b/test/codegen_test.gleam index 3d65cd9..48a48d1 100644 --- a/test/codegen_test.gleam +++ b/test/codegen_test.gleam @@ -57,7 +57,14 @@ pub fn render_queries_module_test() { pub fn render_params_module_test() { let naming_ctx = naming.new() let analyzed = analyzed_queries("test/fixtures/query.sql") - let rendered = params.render(naming_ctx, analyzed, model.StringMapping, "db") + let rendered = + params.render( + naming_ctx, + analyzed, + model.StringMapping, + "db", + "sqlode/runtime", + ) string.contains(rendered, "pub type GetAuthorParams {") |> should.be_true() @@ -183,6 +190,7 @@ pub fn render_sqlight_adapter_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -213,7 +221,14 @@ pub fn render_sqlight_adapter_test() { pub fn render_params_module_slice_test() { let naming_ctx = naming.new() let analyzed = analyzed_slice_queries(model.PostgreSQL) - let rendered = params.render(naming_ctx, analyzed, model.StringMapping, "db") + let rendered = + params.render( + naming_ctx, + analyzed, + model.StringMapping, + "db", + "sqlode/runtime", + ) // The type should use List(...) for slice params string.contains(rendered, "ids: List(") @@ -243,6 +258,7 @@ pub fn render_pog_adapter_slice_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -278,6 +294,7 @@ pub fn render_sqlight_adapter_slice_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -435,6 +452,7 @@ pub fn render_adapter_uses_table_constructor_for_match_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -479,6 +497,7 @@ fn test_block() -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -497,6 +516,7 @@ fn test_block_native() -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -579,6 +599,7 @@ pub fn render_enum_decoder_uses_decode_then_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -650,6 +671,7 @@ pub fn render_pog_adapter_enum_slice_converts_to_string_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -689,6 +711,7 @@ pub fn render_sqlight_adapter_enum_slice_converts_to_string_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -711,7 +734,14 @@ pub fn render_sqlight_adapter_enum_slice_converts_to_string_test() { pub fn readme_params_snapshot_test() { let naming_ctx = naming.new() let analyzed = readme_analyzed_queries() - let rendered = params.render(naming_ctx, analyzed, model.StringMapping, "db") + let rendered = + params.render( + naming_ctx, + analyzed, + model.StringMapping, + "db", + "sqlode/runtime", + ) // README: pub type GetAuthorParams { GetAuthorParams(id: Int) } string.contains(rendered, "pub type GetAuthorParams {") @@ -837,6 +867,7 @@ fn readme_test_block() -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -899,7 +930,14 @@ pub fn render_models_with_array_columns_test() { pub fn render_params_with_array_columns_test() { let naming_ctx = naming.new() let analyzed = array_analyzed_queries() - let rendered = params.render(naming_ctx, analyzed, model.StringMapping, "db") + let rendered = + params.render( + naming_ctx, + analyzed, + model.StringMapping, + "db", + "sqlode/runtime", + ) // Params for CreateArticle should have array fields (nullable since no NOT NULL) string.contains(rendered, "tags: Option(List(String))") @@ -911,7 +949,14 @@ pub fn render_params_with_array_columns_test() { pub fn render_params_array_encoding_raw_runtime_test() { let naming_ctx = naming.new() let analyzed = array_analyzed_queries() - let rendered = params.render(naming_ctx, analyzed, model.StringMapping, "db") + let rendered = + params.render( + naming_ctx, + analyzed, + model.StringMapping, + "db", + "sqlode/runtime", + ) // Raw-mode array params should encode using runtime.array + list.map string.contains(rendered, "runtime.array(list.map(") @@ -941,6 +986,7 @@ pub fn render_pog_adapter_with_array_columns_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) diff --git a/test/generate_test.gleam b/test/generate_test.gleam index 23cdf05..015cdf4 100644 --- a/test/generate_test.gleam +++ b/test/generate_test.gleam @@ -26,6 +26,7 @@ fn base_block(overrides: model.Overrides) -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: overrides, ) @@ -282,6 +283,7 @@ pub fn module_qualified_custom_type_in_params_generates_import_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.Overrides( type_overrides: [ @@ -362,6 +364,7 @@ pub fn rich_type_mapping_emits_semantic_aliases_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -400,6 +403,7 @@ pub fn strong_type_mapping_emits_wrapper_types_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -481,6 +485,7 @@ pub fn exact_table_match_produces_alias_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -632,6 +637,7 @@ fn join_rename_block(renames: List(model.ColumnRename)) -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.Overrides(type_overrides: [], column_renames: renames), ) @@ -781,6 +787,7 @@ fn nullable_block(overrides: model.Overrides) -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: overrides, ) @@ -887,6 +894,7 @@ fn all_commands_block( emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1021,6 +1029,7 @@ pub fn run_with_missing_schema_file_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1046,6 +1055,7 @@ pub fn run_with_missing_query_file_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1072,6 +1082,7 @@ pub fn run_with_no_queries_in_file_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1098,6 +1109,7 @@ pub fn execresult_rejected_on_native_runtime_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1123,6 +1135,7 @@ pub fn execresult_allowed_on_raw_runtime_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1147,6 +1160,7 @@ fn unsupported_annotation_block(query_file: String) -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1209,6 +1223,7 @@ fn compound_block() -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1289,6 +1304,7 @@ fn view_block() -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1366,6 +1382,7 @@ pub fn invalid_out_path_rejected_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1398,6 +1415,7 @@ pub fn accept_directory_for_schema_and_queries_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1428,6 +1446,7 @@ pub fn mixed_file_and_directory_inputs_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1454,6 +1473,7 @@ pub fn reject_empty_schema_directory_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1480,6 +1500,7 @@ pub fn reject_empty_query_directory_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1508,6 +1529,7 @@ pub fn reject_duplicate_query_names_test() { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1537,6 +1559,7 @@ pub fn emit_sql_as_comment_includes_sql_in_output_test() { emit_sql_as_comment: True, emit_exact_table_names: False, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1578,6 +1601,7 @@ pub fn emit_exact_table_names_skips_singularization_test() { emit_sql_as_comment: False, emit_exact_table_names: True, omit_unused_models: False, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1621,6 +1645,7 @@ fn multi_table_block(omit_unused_models: Bool) -> model.SqlBlock { emit_sql_as_comment: False, emit_exact_table_names: False, omit_unused_models: omit_unused_models, + vendor_runtime: False, ), overrides: model.empty_overrides(), ) @@ -1655,3 +1680,69 @@ pub fn omit_unused_models_drops_unreferenced_tables_test() { cleanup() } + +// vendor_runtime tests (Issue #302) + +fn vendor_runtime_block(vendor_runtime: Bool) -> model.SqlBlock { + model.SqlBlock( + name: option.None, + engine: model.PostgreSQL, + schema: ["test/fixtures/schema.sql"], + queries: ["test/fixtures/query.sql"], + gleam: model.GleamOutput( + out: test_out, + runtime: model.Raw, + type_mapping: model.StringMapping, + emit_sql_as_comment: False, + emit_exact_table_names: False, + omit_unused_models: False, + vendor_runtime: vendor_runtime, + ), + overrides: model.empty_overrides(), + ) +} + +pub fn vendor_runtime_default_imports_shared_runtime_test() { + cleanup() + run_generate(vendor_runtime_block(False)) + let params = read_generated("params.gleam") + let queries = read_generated("queries.gleam") + let exists = case simplifile.read(test_out <> "/runtime.gleam") { + Ok(_) -> True + Error(_) -> False + } + + // Default behaviour: import sqlode/runtime, no vendored file. + string.contains(params, "import sqlode/runtime.{type Value}") + |> should.be_true + string.contains(queries, "import sqlode/runtime") |> should.be_true + exists |> should.be_false + + cleanup() +} + +pub fn vendor_runtime_emits_local_copy_and_rewrites_imports_test() { + cleanup() + run_generate(vendor_runtime_block(True)) + let params = read_generated("params.gleam") + let queries = read_generated("queries.gleam") + let runtime = read_generated("runtime.gleam") + let actual_runtime = case simplifile.read("src/sqlode/runtime.gleam") { + Ok(content) -> content + Error(_) -> "" + } + + // Vendored: imports point at the local copy and no sqlode/runtime + // reference leaks through. + string.contains(params, "import sqlode/runtime") |> should.be_false + string.contains(params, "import") |> should.be_true + string.contains(queries, "import sqlode/runtime") |> should.be_false + + // The local copy must end up identical to the sqlode/runtime source, + // byte-for-byte, so the same API the generated code expects is + // available. The module_path prefix is applied at import sites, not + // inside the runtime file itself. + runtime |> should.equal(actual_runtime) + + cleanup() +}