From c8ec2ef7e06b78ddd1c0169a3682a0dc60233658 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 6 Nov 2025 15:27:50 +0000 Subject: [PATCH] Support installing with SQL schema format As with the schema format, we'll just add a structure file containing the SQL for creating the cache tables as this will work with `bin/rails db:prepare` even if you only run the cache database in production. We bundle the SQL files for MySQL, PostgreSQL, and SQLite - they are generated with `bin/generate_sql_schemas`. --- .github/workflows/main.yml | 4 +- README.md | 12 +- bin/generate_structure_sql | 94 +++++++++++++ bin/generate_structure_sqls | 28 ++++ docker-compose.yml | 4 +- .../solid_cache/install/install_generator.rb | 45 +++++- .../templates/db/cache_structure.mysql.sql | 56 ++++++++ .../db/cache_structure.postgresql.sql | 128 ++++++++++++++++++ .../templates/db/cache_structure.sqlite3.sql | 6 + .../solid_cache/install_generator_test.rb | 42 ++++++ 10 files changed, 412 insertions(+), 7 deletions(-) create mode 100755 bin/generate_structure_sql create mode 100755 bin/generate_structure_sqls create mode 100644 lib/generators/solid_cache/install/templates/db/cache_structure.mysql.sql create mode 100644 lib/generators/solid_cache/install/templates/db/cache_structure.postgresql.sql create mode 100644 lib/generators/solid_cache/install/templates/db/cache_structure.sqlite3.sql diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index eaad8827..52fefb06 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -43,13 +43,13 @@ jobs: ports: - 6379:6379 postgres: - image: postgres:15.1 + image: postgres:17 env: POSTGRES_HOST_AUTH_METHOD: "trust" ports: - 55432:5432 mysql: - image: mysql:8.0.31 + image: mysql:9 env: MYSQL_ALLOW_EMPTY_PASSWORD: "yes" ports: diff --git a/README.md b/README.md index 5ac3c1a4..282116a9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,13 @@ Solid Cache is configured by default in new Rails 8 applications. But if you're 1. `bundle add solid_cache` 2. `bin/rails solid_cache:install` -This will configure Solid Cache as the production cache store, create `config/cache.yml`, and create `db/cache_schema.rb`. +This will configure Solid Cache as the production cache store and create `config/cache.yml`. + +If your application uses `config.active_record.schema_format = :ruby` (the default), the installer creates `db/cache_schema.rb`. + +If your application uses `config.active_record.schema_format = :sql`, the installer creates `db/cache_structure.sql` with the appropriate SQL for your database adapter (PostgreSQL, MySQL, or SQLite). + +### Configuring the cache database You will then have to add the configuration for the cache database in `config/database.yml`. If you're using sqlite, it'll look like this: @@ -39,7 +45,9 @@ production: migrations_paths: db/cache_migrate ``` -Then run `db:prepare` in production to ensure the database is created and the schema is loaded. +### Finalizing installation + +After configuring `database.yml`, run `db:prepare` in production to ensure the cache database is created and the schema is loaded. ## Configuration diff --git a/bin/generate_structure_sql b/bin/generate_structure_sql new file mode 100755 index 00000000..e1fbccbd --- /dev/null +++ b/bin/generate_structure_sql @@ -0,0 +1,94 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Called by bin/generate_sql_schemas with TARGET_DB environment variable set + +require "bundler/setup" +require "fileutils" + +# Change to test/dummy directory +dummy_dir = File.expand_path("../test/dummy", __dir__) +Dir.chdir(dummy_dir) + +# Load the test/dummy Rails app +APP_PATH = File.expand_path("config/application", Dir.pwd) +require File.expand_path("config/boot", Dir.pwd) +require APP_PATH +Dummy::Application.load_tasks + +# Patch Rails to use Docker containers for mysqldump/pg_dump for repeatable results +module DockerDatabaseCommands + private + def run_cmd(cmd, *args, **opts) + case cmd + when "mysqldump" + database, output_file = extract_database_and_output_file(args, file_arg: "--result-file") + docker_cmd = ["docker", "exec", "solid_cache-mysql-1", "mysqldump", + "--set-gtid-purged=OFF", "--no-data", "--routines", "--skip-comments", database] + + fail run_cmd_error(cmd, args, "structure_dump") unless Kernel.system(*docker_cmd, out: output_file, **opts) + when "pg_dump" + database, output_file = extract_database_and_output_file(args, file_arg: "--file") + docker_cmd = ["docker", "exec", "solid_cache-postgres-1", "pg_dump", + "-U", "postgres", "--schema-only", "--no-privileges", "--no-owner", database] + + fail run_cmd_error(cmd, args) unless Kernel.system(*docker_cmd, out: output_file, **opts) + else + super + end + end + + def extract_database_and_output_file(args, file_arg:) + args = args.flatten + database = args.last + output_file = nil + + args.each_with_index do |arg, i| + if arg == file_arg + output_file = args[i + 1] + break + end + end + + [database, output_file] + end +end + +ActiveRecord::Tasks::MySQLDatabaseTasks.prepend(DockerDatabaseCommands) +ActiveRecord::Tasks::PostgreSQLDatabaseTasks.prepend(DockerDatabaseCommands) + +OUTPUT_FILES = { + "sqlite" => "cache_structure.sqlite3.sql", + "mysql" => "cache_structure.mysql.sql", + "postgres" => "cache_structure.postgresql.sql" +} + +target_db = ENV["TARGET_DB"] || "sqlite" +output_file = OUTPUT_FILES[target_db] + +unless output_file + puts " ✗ Error: Unknown TARGET_DB: #{target_db}" + exit 1 +end + +templates_dir = File.expand_path("../../lib/generators/solid_cache/install/templates/db", Dir.pwd) +output_path = File.join(templates_dir, output_file) +cache_schema_path = File.join(templates_dir, "cache_schema.rb") + +begin + puts " Creating database..." + Rake::Task["db:create"].invoke + + puts " Loading Ruby schema..." + load cache_schema_path + + puts " Dumping structure..." + db_config = ActiveRecord::Base.connection_db_config + ActiveRecord::Tasks::DatabaseTasks.structure_dump(db_config, output_path) + + puts " ✓ Saved to #{output_file}" +rescue => e + puts " ✗ Error: #{e.message}" + puts " #{e.backtrace.first(5).join("\n ")}" + exit 1 +end diff --git a/bin/generate_structure_sqls b/bin/generate_structure_sqls new file mode 100755 index 00000000..15e258d3 --- /dev/null +++ b/bin/generate_structure_sqls @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# frozen_string_literal: true + +# Generate SQL schema files for PostgreSQL, MySQL, and SQLite +# These files are used when installing SolidCache with schema_format = :sql + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +export BUNDLE_GEMFILE="$PROJECT_ROOT/gemfiles/rails_8_1.gemfile" + +echo "Generating SQL schema files for SolidCache..." +echo "" + +for db in sqlite mysql postgres; do + echo "==> Generating schema for $db..." + TARGET_DB=$db "$SCRIPT_DIR/generate_structure_sql" +done + +echo "" +echo "✓ All SQL schema files generated successfully!" +echo "" +echo "Generated files:" +echo " - cache_structure.sqlite3.sql" +echo " - cache_structure.mysql.sql" +echo " - cache_structure.postgresql.sql" diff --git a/docker-compose.yml b/docker-compose.yml index cb22ea03..05b5a4f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,14 +6,14 @@ volumes: services: postgres: - image: postgres:15.1 + image: postgres:17 environment: POSTGRES_HOST_AUTH_METHOD: "trust" volumes: - postgres:/var/lib/postgres ports: [ "127.0.0.1:55432:5432" ] mysql: - image: mysql:8.0.31 + image: mysql:9 environment: MYSQL_ALLOW_EMPTY_PASSWORD: "yes" volumes: diff --git a/lib/generators/solid_cache/install/install_generator.rb b/lib/generators/solid_cache/install/install_generator.rb index 607c7093..28d55966 100644 --- a/lib/generators/solid_cache/install/install_generator.rb +++ b/lib/generators/solid_cache/install/install_generator.rb @@ -5,11 +5,54 @@ class SolidCache::InstallGenerator < Rails::Generators::Base def copy_files template "config/cache.yml" - template "db/cache_schema.rb" + + if Rails.application.config.active_record.schema_format == :sql + copy_sql_schema_for_adapter + else + template "db/cache_schema.rb" + end end def configure_cache_store_adapter gsub_file Pathname.new(destination_root).join("config/environments/production.rb"), /(# )?config\.cache_store = (:.*)/, "config.cache_store = :solid_cache_store" end + + private + def copy_sql_schema_for_adapter + sql_file = sql_schema_file_for_adapter + + if sql_file + copy_file sql_file, "db/cache_structure.sql" + else + raise_unsupported_adapter_error + end + end + + def sql_schema_file_for_adapter + case ActiveRecord::Base.connection_db_config.adapter + when "postgresql" + "db/cache_structure.postgresql.sql" + when "mysql2", "trilogy" + "db/cache_structure.mysql.sql" + when "sqlite3" + "db/cache_structure.sqlite3.sql" + else + nil + end + end + + def raise_unsupported_adapter_error + error_message = <<~ERROR + + ERROR: Unsupported database adapter for SQL schema format: #{adapter.inspect} + + SolidCache supports installing for the following Rails adapters with schema_format = :sql: + - PostgreSQL (postgresql) + - MySQL (mysql2, trilogy) + - SQLite (sqlite3) + ERROR + + raise error_message + end end diff --git a/lib/generators/solid_cache/install/templates/db/cache_structure.mysql.sql b/lib/generators/solid_cache/install/templates/db/cache_structure.mysql.sql new file mode 100644 index 00000000..951992e8 --- /dev/null +++ b/lib/generators/solid_cache/install/templates/db/cache_structure.mysql.sql @@ -0,0 +1,56 @@ + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +DROP TABLE IF EXISTS `ar_internal_metadata`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ar_internal_metadata` ( + `key` varchar(255) NOT NULL, + `value` varchar(255) DEFAULT NULL, + `created_at` datetime(6) NOT NULL, + `updated_at` datetime(6) NOT NULL, + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `schema_migrations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `schema_migrations` ( + `version` varchar(255) NOT NULL, + PRIMARY KEY (`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `solid_cache_entries`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `solid_cache_entries` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `key` varbinary(1024) NOT NULL, + `value` longblob NOT NULL, + `created_at` datetime(6) NOT NULL, + `key_hash` bigint NOT NULL, + `byte_size` int NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `index_solid_cache_entries_on_key_hash` (`key_hash`), + KEY `index_solid_cache_entries_on_byte_size` (`byte_size`), + KEY `index_solid_cache_entries_on_key_hash_and_byte_size` (`key_hash`,`byte_size`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + diff --git a/lib/generators/solid_cache/install/templates/db/cache_structure.postgresql.sql b/lib/generators/solid_cache/install/templates/db/cache_structure.postgresql.sql new file mode 100644 index 00000000..31898663 --- /dev/null +++ b/lib/generators/solid_cache/install/templates/db/cache_structure.postgresql.sql @@ -0,0 +1,128 @@ +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: ar_internal_metadata; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.ar_internal_metadata ( + key character varying NOT NULL, + value character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.schema_migrations ( + version character varying NOT NULL +); + + +-- +-- Name: solid_cache_entries; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.solid_cache_entries ( + id bigint NOT NULL, + key bytea NOT NULL, + value bytea NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + key_hash bigint NOT NULL, + byte_size integer NOT NULL +); + + +-- +-- Name: solid_cache_entries_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.solid_cache_entries_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: solid_cache_entries_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.solid_cache_entries_id_seq OWNED BY public.solid_cache_entries.id; + + +-- +-- Name: solid_cache_entries id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.solid_cache_entries ALTER COLUMN id SET DEFAULT nextval('public.solid_cache_entries_id_seq'::regclass); + + +-- +-- Name: ar_internal_metadata ar_internal_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ar_internal_metadata + ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key); + + +-- +-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.schema_migrations + ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); + + +-- +-- Name: solid_cache_entries solid_cache_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.solid_cache_entries + ADD CONSTRAINT solid_cache_entries_pkey PRIMARY KEY (id); + + +-- +-- Name: index_solid_cache_entries_on_byte_size; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_solid_cache_entries_on_byte_size ON public.solid_cache_entries USING btree (byte_size); + + +-- +-- Name: index_solid_cache_entries_on_key_hash; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_solid_cache_entries_on_key_hash ON public.solid_cache_entries USING btree (key_hash); + + +-- +-- Name: index_solid_cache_entries_on_key_hash_and_byte_size; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_solid_cache_entries_on_key_hash_and_byte_size ON public.solid_cache_entries USING btree (key_hash, byte_size); + + +-- +-- PostgreSQL database dump complete +-- + +SET search_path TO "$user", public; + diff --git a/lib/generators/solid_cache/install/templates/db/cache_structure.sqlite3.sql b/lib/generators/solid_cache/install/templates/db/cache_structure.sqlite3.sql new file mode 100644 index 00000000..f39e3fc7 --- /dev/null +++ b/lib/generators/solid_cache/install/templates/db/cache_structure.sqlite3.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY); +CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE TABLE IF NOT EXISTS "solid_cache_entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "key" blob(1024) NOT NULL, "value" blob(536870912) NOT NULL, "created_at" datetime(6) NOT NULL, "key_hash" integer(8) NOT NULL, "byte_size" integer(4) NOT NULL); +CREATE INDEX "index_solid_cache_entries_on_byte_size" ON "solid_cache_entries" ("byte_size"); +CREATE INDEX "index_solid_cache_entries_on_key_hash_and_byte_size" ON "solid_cache_entries" ("key_hash", "byte_size"); +CREATE UNIQUE INDEX "index_solid_cache_entries_on_key_hash" ON "solid_cache_entries" ("key_hash"); diff --git a/test/lib/generators/solid_cache/solid_cache/install_generator_test.rb b/test/lib/generators/solid_cache/solid_cache/install_generator_test.rb index 37034cb0..fbd5af52 100644 --- a/test/lib/generators/solid_cache/solid_cache/install_generator_test.rb +++ b/test/lib/generators/solid_cache/solid_cache/install_generator_test.rb @@ -14,6 +14,11 @@ class SolidCache::InstallGeneratorTest < Rails::Generators::TestCase dummy_app_fixture = File.expand_path("../../../../fixtures/generators/dummy_app", __dir__) files = Dir.glob("#{dummy_app_fixture}/*") FileUtils.cp_r(files, destination_root) + @old_schema_format = Rails.application.config.active_record.schema_format + end + + teardown do + Rails.application.config.active_record.schema_format = @old_schema_format end test "generator updates environment config" do @@ -26,6 +31,43 @@ class SolidCache::InstallGeneratorTest < Rails::Generators::TestCase assert_file "#{destination_root}/config/environments/production.rb", /config.cache_store = :solid_cache_store\n/ end + test "generator creates SQL structure file when schema_format is sql" do + Rails.application.config.active_record.schema_format = :sql + + run_generator + + assert_file "#{destination_root}/config/cache.yml", expected_cache_config + assert_no_file "#{destination_root}/db/cache_schema.rb" + + # Check that a SQL structure file was created with database-specific syntax + assert_file "#{destination_root}/db/cache_structure.sql" do |content| + assert_match(/CREATE TABLE.*solid_cache_entries/, content) + + # Check for database-specific column types + case ActiveRecord::Base.connection_db_config.adapter + when "postgresql" + assert_match(/key.*bytea/, content) + assert_match(/value.*bytea/, content) + assert_match(/key_hash.*bigint/, content) + assert_match(/byte_size.*integer/, content) + when "mysql2", "trilogy" + assert_match(/key.*varbinary/, content) + assert_match(/value.*longblob/, content) + assert_match(/key_hash.*bigint/, content) + assert_match(/byte_size.*int/, content) + when "sqlite3" + assert_match(/key.*blob/, content) + assert_match(/value.*blob/, content) + assert_match(/key_hash.*integer/, content) + assert_match(/byte_size.*integer/, content) + end + + assert_match(/index.*key_hash/, content) + end + + assert_file "#{destination_root}/config/environments/production.rb", /config.cache_store = :solid_cache_store\n/ + end + private def expected_cache_config <<~YAML