├── log └── .keep ├── tmp ├── .keep └── pids │ └── .keep ├── vendor └── .keep ├── lib ├── assets │ └── .keep └── tasks │ └── .keep ├── public ├── favicon.ico ├── apple-touch-icon.png ├── apple-touch-icon-precomposed.png ├── robots.txt ├── 500.html ├── 422.html └── 404.html ├── test ├── helpers │ └── .keep ├── models │ └── .keep ├── controllers │ └── .keep ├── integration │ └── .keep ├── fixtures │ └── files │ │ └── .keep └── test_helper.rb ├── app ├── assets │ ├── images │ │ └── .keep │ ├── config │ │ └── manifest.js │ └── stylesheets │ │ └── application.css ├── models │ ├── concerns │ │ └── .keep │ ├── application_record.rb │ ├── specialization.rb │ ├── campaign.rb │ ├── campaigns_player.rb │ ├── spell.rb │ ├── characters_spell.rb │ ├── characters_feat.rb │ ├── skill.rb │ ├── characters_skill.rb │ ├── characters_flaw.rb │ ├── characters_virtue.rb │ ├── characters_specialization.rb │ ├── inventory.rb │ ├── player.rb │ ├── inventories_item.rb │ ├── feat.rb │ ├── flaw.rb │ ├── virtue.rb │ ├── item.rb │ └── character.rb ├── controllers │ ├── concerns │ │ └── .keep │ └── application_controller.rb ├── helpers │ └── application_helper.rb ├── domain │ ├── types.rb │ ├── query_builder │ │ ├── nodes │ │ │ ├── wheres │ │ │ │ ├── base.rb │ │ │ │ ├── not.rb │ │ │ │ ├── and.rb │ │ │ │ ├── or.rb │ │ │ │ └── boolean_operator.rb │ │ │ ├── limit.rb │ │ │ ├── order_by.rb │ │ │ ├── simple.rb │ │ │ ├── base.rb │ │ │ ├── select.rb │ │ │ ├── select_distinct.rb │ │ │ └── joins │ │ │ │ ├── includes.rb │ │ │ │ ├── inner_join.rb │ │ │ │ ├── left_outer_join.rb │ │ │ │ └── includes_references.rb │ │ ├── characters │ │ │ └── nodes │ │ │ │ ├── veteran.rb │ │ │ │ ├── elite_stat.rb │ │ │ │ ├── id_in.rb │ │ │ │ ├── buff.rb │ │ │ │ └── legendary.rb │ │ ├── feats │ │ │ └── nodes │ │ │ │ └── name_in.rb │ │ ├── flaws │ │ │ └── nodes │ │ │ │ └── title_in.rb │ │ ├── spells │ │ │ └── nodes │ │ │ │ ├── name_in.rb │ │ │ │ └── level_in.rb │ │ ├── virtues │ │ │ └── nodes │ │ │ │ └── title_in.rb │ │ ├── campaigns │ │ │ └── nodes │ │ │ │ ├── title_in.rb │ │ │ │ └── time_window.rb │ │ ├── specializations │ │ │ └── nodes │ │ │ │ └── name_in.rb │ │ └── builders │ │ │ ├── default.rb │ │ │ ├── wheres.rb │ │ │ └── base.rb │ └── queries │ │ ├── helpers │ │ ├── wheres.rb │ │ └── joins │ │ │ └── filter.rb │ │ ├── base.rb │ │ ├── characters │ │ ├── character_spells_query.rb │ │ └── index_query.rb │ │ └── campaigns │ │ └── index_query.rb └── views │ └── layouts │ └── application.html.erb ├── .ruby-version ├── .rspec ├── .gitignore ├── config ├── master.key ├── boot.rb ├── environment.rb ├── routes.rb ├── initializers │ ├── filter_parameter_logging.rb │ ├── permissions_policy.rb │ ├── assets.rb │ ├── inflections.rb │ └── content_security_policy.rb ├── credentials.yml.enc ├── locales │ └── en.yml ├── application.rb ├── puma.rb ├── environments │ ├── test.rb │ ├── development.rb │ └── production.rb └── database.yml ├── bin ├── rake ├── rails ├── setup └── bundle ├── config.ru ├── spec ├── factories │ ├── feats.rb │ ├── flaws.rb │ ├── specializations.rb │ ├── virtues.rb │ ├── spells.rb │ ├── campaigns.rb │ └── characters.rb ├── queries │ ├── shared_context │ │ ├── campaigns.rb │ │ └── characters.rb │ ├── campaigns │ │ └── index_query_spec.rb │ └── characters │ │ └── index_query_spec.rb ├── rails_helper.rb └── spec_helper.rb ├── CODEOWNERS ├── Rakefile ├── LICENSE ├── Gemfile ├── db ├── seeds.rb ├── schema.rb └── migrate │ └── 20230414172406_create_starting_schema.rb ├── Gemfile.lock └── README.md /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.2.0 2 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /log/* 2 | /tmp/* 3 | .idea -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/master.key: -------------------------------------------------------------------------------- 1 | 5aabedae418fafb4d938131128ff86e1 -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/domain/types.rb: -------------------------------------------------------------------------------- 1 | require 'dry-types' 2 | 3 | module Types 4 | include Dry.Types 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /spec/factories/feats.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :feat, class: 'Feat' do 3 | name { 'dungeon delving' } 4 | description { 'gets bonuses in dungeons'} 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/factories/flaws.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :flaw, class: 'Flaw' do 3 | title { 'forgetful' } 4 | description { '-2 to memory' } 5 | modify_strength { -2 } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/factories/specializations.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :specialization, class: 'Specialization' do 3 | name { 'Ranger' } 4 | description { 'likes nature and ranged weapons' } 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/factories/virtues.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :virtue, class: 'Virtue' do 3 | title { 'great strength' } 4 | description { '+2 to strength' } 5 | modify_strength { 2 } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/factories/spells.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :spell, class: 'Spell' do 3 | name { 'greater illusion' } 4 | description { 'produces a powerful illusion effect' } 5 | level { 4 } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in the repo. 5 | * @justinddaniel @JohnP42 6 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 3 | 4 | # Defines the root path route ("/") 5 | # root "articles#index" 6 | end 7 | -------------------------------------------------------------------------------- /spec/factories/campaigns.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :campaign, class: 'Campaign' do 3 | title { Faker::Book.title } 4 | description { Faker::Quote.fortune_cookie } 5 | start_date { Time.zone.now } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/wheres/base.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Wheres 4 | class Base < ::QueryBuilder::Nodes::Base 5 | include QueryBuilder::Nodes::Simple 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/limit.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | class Limit < QueryBuilder::Nodes::Base 4 | param :numeric 5 | 6 | def accept(state) 7 | state.limit(numeric) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/order_by.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | class OrderBy < QueryBuilder::Nodes::Base 4 | param :sort 5 | 6 | def accept(state) 7 | state.order(sort) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/domain/queries/helpers/wheres.rb: -------------------------------------------------------------------------------- 1 | module Queries 2 | module Helpers 3 | module Wheres 4 | def wheres_operator(operator, *clauses) 5 | "QueryBuilder::Nodes::Wheres::#{operator.to_s.camelize}".constantize.new(*clauses) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/simple.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Simple 4 | def clause 5 | raise NotImplementedError 6 | end 7 | 8 | def accept(state) 9 | state.merge(clause) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/base.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | class Base 4 | extend Dry::Initializer 5 | 6 | def accept(_state) 7 | raise NotImplementedError 8 | end 9 | 10 | def valid? 11 | true 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/domain/query_builder/characters/nodes/veteran.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Characters 3 | module Nodes 4 | class Veteran < ::QueryBuilder::Nodes::Wheres::Base 5 | def clause 6 | Character.where('experience > 10000') 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/select.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | class Select < ::QueryBuilder::Nodes::Base 4 | include QueryBuilder::Nodes::Simple 5 | 6 | param :relation 7 | param :columns 8 | 9 | def clause 10 | relation.select(columns) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/domain/query_builder/characters/nodes/elite_stat.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Characters 3 | module Nodes 4 | class EliteStat < QueryBuilder::Nodes::Wheres::Base 5 | param :stat_name 6 | 7 | def clause 8 | Character.where("#{stat_name} >= 18") 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/select_distinct.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | class SelectDistinct < ::QueryBuilder::Nodes::Base 4 | include QueryBuilder::Nodes::Simple 5 | 6 | param :relation 7 | param :columns 8 | 9 | def clause 10 | relation.select(columns).distinct 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/wheres/not.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Wheres 4 | class Not < ::QueryBuilder::Nodes::Wheres::Base 5 | param :nested 6 | 7 | delegate :valid?, to: :nested 8 | 9 | def clause 10 | nested.clause.invert_where 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Rails2023Showcase 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag "application" %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/domain/query_builder/characters/nodes/id_in.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Characters 3 | module Nodes 4 | class IdIn < QueryBuilder::Nodes::Wheres::Base 5 | param :ids 6 | 7 | def clause 8 | Spell.where(id: ids) 9 | end 10 | 11 | def valid? 12 | ids.present? 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/domain/query_builder/feats/nodes/name_in.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Feats 3 | module Nodes 4 | class NameIn < QueryBuilder::Nodes::Wheres::Base 5 | param :names 6 | 7 | def clause 8 | Feat.where(name: names) 9 | end 10 | 11 | def valid? 12 | names.present? 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/joins/includes.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Joins 4 | class Includes < QueryBuilder::Nodes::Base 5 | include QueryBuilder::Nodes::Simple 6 | 7 | param :table1 8 | param :table2 9 | 10 | def clause 11 | table1.includes(table2) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/domain/query_builder/flaws/nodes/title_in.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Flaws 3 | module Nodes 4 | class TitleIn < QueryBuilder::Nodes::Wheres::Base 5 | param :titles 6 | 7 | def clause 8 | Flaw.where(title: titles) 9 | end 10 | 11 | def valid? 12 | titles.present? 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/joins/inner_join.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Joins 4 | class InnerJoin < ::QueryBuilder::Nodes::Base 5 | include QueryBuilder::Nodes::Simple 6 | 7 | param :table1 8 | param :table2 9 | 10 | def clause 11 | table1.joins(table2) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/domain/query_builder/spells/nodes/name_in.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Spells 3 | module Nodes 4 | class NameIn < QueryBuilder::Nodes::Wheres::Base 5 | param :names 6 | 7 | def clause 8 | Spell.where(name: names) 9 | end 10 | 11 | def valid? 12 | names.present? 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/wheres/and.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Wheres 4 | class And < QueryBuilder::Nodes::Wheres::Base 5 | include QueryBuilder::Nodes::Wheres::BooleanOperator 6 | 7 | def initialize(*child_nodes) 8 | @builder = Builder.new(operator: :and).add(*child_nodes) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/domain/query_builder/spells/nodes/level_in.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Spells 3 | module Nodes 4 | class LevelIn < QueryBuilder::Nodes::Wheres::Base 5 | param :levels 6 | 7 | def clause 8 | Spell.where(level: levels) 9 | end 10 | 11 | def valid? 12 | levels.present? 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/joins/left_outer_join.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Joins 4 | class LeftOuterJoin < QueryBuilder::Nodes::Base 5 | include QueryBuilder::Nodes::Simple 6 | 7 | param :table1 8 | param :table2 9 | 10 | def clause 11 | table1.left_joins(table2) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/domain/query_builder/virtues/nodes/title_in.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Virtues 3 | module Nodes 4 | class TitleIn < QueryBuilder::Nodes::Wheres::Base 5 | param :titles 6 | 7 | def clause 8 | Virtue.where(title: titles) 9 | end 10 | 11 | def valid? 12 | titles.present? 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/domain/query_builder/campaigns/nodes/title_in.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Campaigns 3 | module Nodes 4 | class TitleIn < QueryBuilder::Nodes::Wheres::Base 5 | param :titles 6 | 7 | def clause 8 | Campaign.where(title: titles) 9 | end 10 | 11 | def valid? 12 | titles.present? 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/wheres/or.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Wheres 4 | class Or < QueryBuilder::Nodes::Wheres::Base 5 | include QueryBuilder::Nodes::Wheres::BooleanOperator 6 | 7 | def initialize(*child_nodes) 8 | @builder = Builder.new(operator: :or).add(*child_nodes) 9 | super 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/domain/query_builder/specializations/nodes/name_in.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Specializations 3 | module Nodes 4 | class NameIn < QueryBuilder::Nodes::Wheres::Base 5 | param :names 6 | 7 | def clause 8 | Specialization.where(name: names) 9 | end 10 | 11 | def valid? 12 | names.present? 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/specialization.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: specializations 4 | # 5 | # id :bigint not null, primary key 6 | # name :string not null 7 | # description :text not null 8 | # properties :json 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | class Specialization < ApplicationRecord 13 | end 14 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/joins/includes_references.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Joins 4 | class IncludesReferences < QueryBuilder::Nodes::Base 5 | include QueryBuilder::Nodes::Simple 6 | 7 | param :table1 8 | param :table2 9 | 10 | def clause 11 | table1.includes(table2).references(table2) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/models/campaign.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: campaigns 4 | # 5 | # id :bigint not null, primary key 6 | # title :string not null 7 | # description :text 8 | # start_date :datetime not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | class Campaign < ApplicationRecord 13 | has_many :characters 14 | end 15 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] ||= "test" 2 | require_relative "../config/environment" 3 | require "rails/test_help" 4 | 5 | class ActiveSupport::TestCase 6 | # Run tests in parallel with specified workers 7 | parallelize(workers: :number_of_processors) 8 | 9 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 10 | fixtures :all 11 | 12 | # Add more helper methods to be used by all tests here... 13 | end 14 | -------------------------------------------------------------------------------- /app/models/campaigns_player.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: campaigns_players 4 | # 5 | # id :bigint not null, primary key 6 | # campaign_id :integer not null 7 | # player_id :integer not null 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # 11 | class CampaignsPlayer < ApplicationRecord 12 | belongs_to :campaign 13 | belongs_to :player 14 | end 15 | -------------------------------------------------------------------------------- /app/models/spell.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: spells 4 | # 5 | # id :bigint not null, primary key 6 | # name :string not null 7 | # description :text not null 8 | # level :integer default(1), not null 9 | # effects :json 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # 13 | class Spell < ApplicationRecord 14 | end 15 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | EllA+heQjdKEO9mW4R88BjyUX3AUB0SeQH1CWpNwTW3BJcFllPkjR5BVzPJX2yl7lIZX/wysyLKUB+aF/F6XEXA3rUNv6UzfspYP3apj0+BnD8FB+xTEhqbYdK4zvDzFXZosyZG5q4SGdX83RxjDmej25yYo7E7RuzUMMeUyLHo4IE4yehv6mGu0VOdxDZZy2W8Fw0mLyPShEOdCjINc6jXfaMsWguKKHsGF0JcOlVT7atQ9eVJDty0kUzh8di344LyKUbvmTAERZZqohmUMnMOQ22b0XEZGDViutC2Pc9i5GGjUAhDqTSEcriLqYu7qWrAleyGC6dgncZZ5twp1ekLq4OBHHpWqf9oq6+f/8poLmNYThwm8FoRMjsVU0ueDdVExfFHEqtr+6LpWVDQWukULrd56nP6lLNeO--D4kbu+ll54+5LNQC--tVHv/Ykil04zIc4tEmpXYQ== -------------------------------------------------------------------------------- /app/models/characters_spell.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: characters_spells 4 | # 5 | # id :bigint not null, primary key 6 | # character_id :integer not null 7 | # spell_id :integer not null 8 | # backstory :text 9 | # modifications :json 10 | # 11 | class CharactersSpell < ApplicationRecord 12 | self.table_name = 'characters_spells' 13 | 14 | belongs_to :character 15 | belongs_to :spell 16 | end 17 | -------------------------------------------------------------------------------- /app/domain/query_builder/builders/default.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Builders 3 | class Default < ::QueryBuilder::Builders::Base 4 | option :initial_state 5 | 6 | private 7 | 8 | def reduce(&block) 9 | nodes.select(&:valid?).reduce(initial_state, &block) 10 | end 11 | 12 | def base_node_class 13 | ::QueryBuilder::Nodes::Base 14 | end 15 | 16 | def visit(state, node) 17 | node.accept(state) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/models/characters_feat.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: characters_feats 4 | # 5 | # id :bigint not null, primary key 6 | # feat_id :integer not null 7 | # character_id :integer not null 8 | # backstory :text 9 | # modifications :json 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # 13 | class CharactersFeat < ApplicationRecord 14 | belongs_to :character 15 | belongs_to :feat 16 | end 17 | -------------------------------------------------------------------------------- /app/models/skill.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: skills 4 | # 5 | # id :bigint not null, primary key 6 | # name :string not null 7 | # description :text not null 8 | # core :boolean 9 | # primary_stat :string 10 | # secondary_stat :string 11 | # tertiary_stat :string 12 | # requirements :json 13 | # created_at :datetime not null 14 | # updated_at :datetime not null 15 | # 16 | class Skill < ApplicationRecord 17 | end 18 | -------------------------------------------------------------------------------- /app/domain/query_builder/builders/wheres.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Builders 3 | class Wheres < ::QueryBuilder::Builders::Base 4 | private 5 | 6 | def base_node_class 7 | QueryBuilder::Nodes::Wheres::Base 8 | end 9 | 10 | def reduce(&block) 11 | valid_nodes = nodes.select(&:valid?) 12 | raise 'no valid nodes' if valid_nodes.empty? 13 | head_node, *tail_nodes = valid_nodes 14 | tail_nodes.reduce(head_node.clause, &block) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/domain/queries/helpers/joins/filter.rb: -------------------------------------------------------------------------------- 1 | module Queries 2 | module Helpers 3 | module Joins 4 | module Filter 5 | def apply_joins 6 | tables.select { |table_name| filters[table_name].present? }.each do |table_name| 7 | builder.add(::QueryBuilder::Nodes::Joins::InnerJoin.new(initial_state, table_name)) 8 | end 9 | end 10 | 11 | def tables 12 | %i(characters flaws virtues feats spells skills specializations) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/characters_skill.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: characters_skills 4 | # 5 | # id :bigint not null, primary key 6 | # character_id :integer not null 7 | # skill_id :integer not null 8 | # trained_level :integer 9 | # backstory :text 10 | # modifications :json 11 | # created_at :datetime not null 12 | # updated_at :datetime not null 13 | # 14 | class CharactersSkill < ApplicationRecord 15 | belongs_to :character 16 | belongs_to :skill 17 | end 18 | -------------------------------------------------------------------------------- /app/domain/query_builder/campaigns/nodes/time_window.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Campaigns 3 | module Nodes 4 | class TimeWindow < QueryBuilder::Nodes::Wheres::Base 5 | option :start_date_lteq, proc(&:presence) 6 | option :start_date_gteq, proc(&:presence) 7 | 8 | def clause 9 | Campaign.where(start_date: start_date_gteq..start_date_lteq) 10 | end 11 | 12 | def valid? 13 | start_date_gteq.present? || start_date_lteq.present? 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/models/characters_flaw.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: characters_flaws 4 | # 5 | # id :bigint not null, primary key 6 | # character_id :integer not null 7 | # flaw_id :integer not null 8 | # permanence :integer default(3) 9 | # backstory :text 10 | # modifications :json 11 | # created_at :datetime not null 12 | # updated_at :datetime not null 13 | # 14 | class CharactersFlaw < ApplicationRecord 15 | belongs_to :character 16 | belongs_to :flaw 17 | end 18 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /app/models/characters_virtue.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: characters_virtues 4 | # 5 | # id :bigint not null, primary key 6 | # character_id :integer not null 7 | # virtue_id :integer not null 8 | # permanence :integer default(3) 9 | # backstory :text 10 | # modifications :json 11 | # created_at :datetime not null 12 | # updated_at :datetime not null 13 | # 14 | class CharactersVirtue < ApplicationRecord 15 | belongs_to :character 16 | belongs_to :virtue 17 | end 18 | -------------------------------------------------------------------------------- /spec/factories/characters.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :character, class: 'Character' do 3 | name { Faker::FunnyName.name } 4 | experience { 1000 } 5 | size { 'medium' } 6 | species { 'dwarf' } 7 | strength { 10 } 8 | agility { 10 } 9 | health { 10 } 10 | reasoning { 10 } 11 | memory { 10 } 12 | intuition { 10 } 13 | beauty { 10 } 14 | 15 | trait :legendary do 16 | strength { 18 } 17 | experience { 15000 } 18 | end 19 | 20 | trait :with_campaign do 21 | campaign 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/domain/query_builder/nodes/wheres/boolean_operator.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Nodes 3 | module Wheres 4 | module BooleanOperator 5 | class Builder < QueryBuilder::Builders::Wheres 6 | option :operator 7 | 8 | private 9 | 10 | def visit(state, node) 11 | state.send(operator, node.clause) 12 | end 13 | end 14 | 15 | def valid? 16 | @builder.valid? 17 | end 18 | 19 | def clause 20 | @builder.build 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/models/characters_specialization.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: characters_specializations 4 | # 5 | # id :bigint not null, primary key 6 | # character_id :integer not null 7 | # specialization_id :integer not null 8 | # trained_levels :integer default(1), not null 9 | # backstory :text 10 | # modifications :json 11 | # 12 | class CharactersSpecialization < ApplicationRecord 13 | self.table_name = 'characters_specializations' 14 | 15 | belongs_to :character 16 | belongs_to :specialization 17 | end 18 | -------------------------------------------------------------------------------- /app/models/inventory.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: inventories 4 | # 5 | # id :bigint not null, primary key 6 | # character_id :integer 7 | # resource_id :bigint 8 | # resource_type :string 9 | # title :string 10 | # description :text 11 | # weight_limit :float 12 | # item_limit :integer 13 | # special_effects :json 14 | # created_at :datetime not null 15 | # updated_at :datetime not null 16 | # 17 | class Inventory < ApplicationRecord 18 | self.table_name = 'inventories' 19 | 20 | has_many :inventories_items 21 | end 22 | -------------------------------------------------------------------------------- /app/models/player.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: players 4 | # 5 | # id :bigint not null, primary key 6 | # first_name :string 7 | # last_name :string 8 | # middle_name :string 9 | # preferred_name :string 10 | # pronouns :string 11 | # username :string not null 12 | # email :string not null 13 | # created_at :datetime not null 14 | # updated_at :datetime not null 15 | # 16 | class Player < ApplicationRecord 17 | has_many :characters 18 | has_many :campaigns_players 19 | has_many :campaigns, through: :campaigns_players 20 | end 21 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /app/models/inventories_item.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: inventories_items 4 | # 5 | # id :bigint not null, primary key 6 | # inventory_id :integer not null 7 | # item_id :integer not null 8 | # equipped :boolean default(FALSE) 9 | # condition :integer default(100) 10 | # modifications :json 11 | # price :decimal(, ) 12 | # price_unit :string default("gp") 13 | # weight :float 14 | # created_at :datetime not null 15 | # updated_at :datetime not null 16 | # 17 | class InventoriesItem < ApplicationRecord 18 | self.table_name = 'inventories_items' 19 | 20 | belongs_to :inventory 21 | belongs_to :item 22 | end 23 | -------------------------------------------------------------------------------- /spec/queries/shared_context/campaigns.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context 'with common database records' do 2 | let!(:specified_campaign) { create(:campaign, title: 'The adventures of Stinky') } 3 | let!(:generic_campaign) { create(:campaign) } 4 | let!(:old_campaign) { create(:campaign, start_date: 2.years.ago) } 5 | let!(:older_campaign) { create(:campaign, start_date: 10.years.ago) } 6 | let!(:characters_campaign) { create(:campaign) } 7 | 8 | let!(:normal_character) { create(:character, campaign: characters_campaign) } 9 | let!(:legendary_character) { create(:character, :legendary, campaign: characters_campaign) } 10 | let(:all_campaigns) do 11 | [specified_campaign, generic_campaign, old_campaign, older_campaign, characters_campaign] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/domain/query_builder/characters/nodes/buff.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Characters 3 | module Nodes 4 | class Buff < ::QueryBuilder::Nodes::Wheres::Base 5 | include Queries::Helpers::Wheres 6 | attr_reader :builder 7 | 8 | # rubocop:disable Lint/MissingSuper 9 | def initialize 10 | @builder = QueryBuilder::Builders::Wheres.new 11 | end 12 | # rubocop:enable Lint/MissingSuper 13 | 14 | def clause 15 | builder.add(wheres_operator(:or, *child_nodes)) 16 | builder.build 17 | end 18 | 19 | def child_nodes 20 | %w(strength health agility reasoning memory intuition beauty).map do |stat_name| 21 | QueryBuilder::Characters::Nodes::EliteStat.new(stat_name) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/domain/queries/base.rb: -------------------------------------------------------------------------------- 1 | module Queries 2 | class Base 3 | extend Dry::Initializer 4 | include Queries::Helpers::Joins::Filter 5 | include Queries::Helpers::Wheres 6 | 7 | option :builder, default: proc { ::QueryBuilder::Builders::Default.new(initial_state: initial_state) } 8 | option :filters, type: ::Types::Hash.constructor(&:to_h), default: proc { {} } 9 | 10 | def call 11 | apply 12 | builder.build 13 | end 14 | 15 | private 16 | 17 | def initial_state 18 | raise NotImplementedError 19 | end 20 | 21 | def apply 22 | raise NotImplementedError 23 | end 24 | 25 | def left_outer_join(model, table_name) 26 | QueryBuilder::Nodes::Joins::LeftOuterJoin.new(model, table_name) 27 | end 28 | 29 | def inner_join(model, table_name) 30 | QueryBuilder::Nodes::Joins::InnerJoin.new(model, table_name) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/models/feat.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: feats 4 | # 5 | # id :bigint not null, primary key 6 | # name :string not null 7 | # description :text not null 8 | # modify_ac :integer default(0) 9 | # modify_hp :integer default(0) 10 | # modify_age :integer default(0) 11 | # modify_stamina :integer default(0) 12 | # modify_strength :integer default(0) 13 | # modify_agility :integer default(0) 14 | # modify_health :integer default(0) 15 | # modify_reasoning :integer default(0) 16 | # modify_memory :integer default(0) 17 | # modify_intuition :integer default(0) 18 | # modify_beauty :integer default(0) 19 | # created_at :datetime not null 20 | # updated_at :datetime not null 21 | # 22 | class Feat < ApplicationRecord 23 | end 24 | -------------------------------------------------------------------------------- /app/domain/query_builder/builders/base.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Builders 3 | class Base 4 | extend Dry::Initializer 5 | 6 | option :nodes, type: ::Types::Array, default: proc { [] } 7 | 8 | def add(*nodes) 9 | nodes.each do |node| 10 | raise ArgumentError, "node #{node.inspect} is not a #{base_node_class}" unless node.is_a?(base_node_class) 11 | @nodes << node 12 | end 13 | self 14 | end 15 | 16 | def build 17 | reduce do |state, node| 18 | visit(state, node) 19 | end 20 | end 21 | 22 | def valid? 23 | nodes.any?(&:valid?) 24 | end 25 | 26 | private 27 | 28 | def reduce 29 | raise NotImplementedError 30 | end 31 | 32 | def base_node_class 33 | raise NotImplementedError 34 | end 35 | 36 | def visit(_state, _node) 37 | raise NotImplementedError 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/domain/query_builder/characters/nodes/legendary.rb: -------------------------------------------------------------------------------- 1 | module QueryBuilder 2 | module Characters 3 | module Nodes 4 | class Legendary < QueryBuilder::Nodes::Wheres::Base 5 | include Queries::Helpers::Wheres 6 | 7 | attr_reader :builder, :legendary 8 | 9 | # rubocop:disable Lint/MissingSuper 10 | def initialize(legendary) 11 | @legendary = legendary 12 | @builder = QueryBuilder::Builders::Wheres.new 13 | end 14 | # rubocop:enable Lint/MissingSuper 15 | 16 | def clause 17 | builder.add(wheres_operator(:and, *and_filters)) 18 | builder.build 19 | end 20 | 21 | def and_filters 22 | [ 23 | QueryBuilder::Characters::Nodes::Veteran.new, 24 | QueryBuilder::Characters::Nodes::Buff.new 25 | ] 26 | end 27 | 28 | def valid? 29 | legendary.present? 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /app/domain/queries/characters/character_spells_query.rb: -------------------------------------------------------------------------------- 1 | module Queries 2 | module Characters 3 | class CharacterSpellsQuery < Queries::Base 4 | option :filters, type: ::Types::Hash.constructor { |v| v.to_h }, default: proc { {} } 5 | option :limit, type: ::Types::Integer, default: proc { 1000 } 6 | 7 | def apply 8 | builder 9 | .add(QueryBuilder::Nodes::Joins::InnerJoin.new(initial_state, :spells)) 10 | .add(wheres_operator(:and, *and_filters)) 11 | .add(QueryBuilder::Nodes::Limit.new(limit)) 12 | end 13 | 14 | private 15 | 16 | def initial_state 17 | ::Character 18 | end 19 | 20 | def and_filters 21 | [ 22 | QueryBuilder::Characters::Nodes::IdIn.new(filters[:ids]), 23 | QueryBuilder::Spells::Nodes::LevelIn.new(spell_filters[:levels]) 24 | ] 25 | end 26 | 27 | def spell_filters 28 | filters[:spells] || {} 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/models/flaw.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: flaws 4 | # 5 | # id :bigint not null, primary key 6 | # title :string not null 7 | # description :text not null 8 | # magnitude :string 9 | # modify_ac :integer default(0) 10 | # modify_hp :integer default(0) 11 | # modify_age :integer default(0) 12 | # modify_stamina :integer default(0) 13 | # modify_strength :integer default(0) 14 | # modify_agility :integer default(0) 15 | # modify_health :integer default(0) 16 | # modify_reasoning :integer default(0) 17 | # modify_memory :integer default(0) 18 | # modify_intuition :integer default(0) 19 | # modify_beauty :integer default(0) 20 | # special_effects :json 21 | # created_at :datetime not null 22 | # updated_at :datetime not null 23 | # 24 | class Flaw < ApplicationRecord 25 | end 26 | -------------------------------------------------------------------------------- /app/models/virtue.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: virtues 4 | # 5 | # id :bigint not null, primary key 6 | # title :string not null 7 | # description :text not null 8 | # magnitude :string 9 | # modify_ac :integer default(0) 10 | # modify_hp :integer default(0) 11 | # modify_age :integer default(0) 12 | # modify_stamina :integer default(0) 13 | # modify_strength :integer default(0) 14 | # modify_agility :integer default(0) 15 | # modify_health :integer default(0) 16 | # modify_reasoning :integer default(0) 17 | # modify_memory :integer default(0) 18 | # modify_intuition :integer default(0) 19 | # modify_beauty :integer default(0) 20 | # special_effects :json 21 | # created_at :datetime not null 22 | # updated_at :datetime not null 23 | # 24 | class Virtue < ApplicationRecord 25 | end 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 G2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | ruby "3.2.0" 5 | 6 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" 7 | gem "rails", "~> 7.0.4", ">= 7.0.4.3" 8 | 9 | # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] 10 | gem "sprockets-rails" 11 | 12 | # Use postgresql as the database for Active Record 13 | gem "pg", "~> 1.1" 14 | 15 | # Use the Puma web server [https://github.com/puma/puma] 16 | gem "puma", "~> 5.0" 17 | 18 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 19 | gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ] 20 | 21 | gem 'dry-container' 22 | gem 'dry-initializer-rails', require: 'dry-initializer-rails' 23 | gem 'dry-types' 24 | 25 | group :development, :test do 26 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem 27 | gem "debug", platforms: %i[ mri mingw x64_mingw ] 28 | gem 'faker' 29 | gem 'pry' 30 | gem 'pry-rails' 31 | gem 'rspec-rails' 32 | gem 'factory_bot_rails' 33 | end 34 | 35 | group :development do 36 | gem 'annotate' 37 | # Speed up commands on slow machines / big apps [https://github.com/rails/spring] 38 | # gem "spring" 39 | end 40 | 41 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | # require "active_job/railtie" 7 | require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | # require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | # require "action_cable/engine" 15 | require "rails/test_unit/railtie" 16 | 17 | # Require the gems listed in Gemfile, including any gems 18 | # you've limited to :test, :development, or :production. 19 | Bundler.require(*Rails.groups) 20 | 21 | module Rails2023Showcase 22 | class Application < Rails::Application 23 | # Initialize configuration defaults for originally generated Rails version. 24 | config.load_defaults 7.0 25 | 26 | # Configuration for the application, engines, and railties goes here. 27 | # 28 | # These settings can be overridden in specific environments using the files 29 | # in config/environments, which are processed later. 30 | # 31 | # config.time_zone = "Central Time (US & Canada)" 32 | # config.eager_load_paths << Rails.root.join("extras") 33 | 34 | # Don't generate system test files. 35 | config.generators.system_tests = nil 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/models/item.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: items 4 | # 5 | # id :bigint not null, primary key 6 | # name :string not null 7 | # description :text 8 | # hardness :integer 9 | # durability :integer 10 | # weight :float 11 | # size :string 12 | # modify_ac :integer default(0) 13 | # modify_hp :integer default(0) 14 | # modify_age :integer default(0) 15 | # modify_stamina :integer default(0) 16 | # modify_strength :integer default(0) 17 | # modify_agility :integer default(0) 18 | # modify_health :integer default(0) 19 | # modify_reasoning :integer default(0) 20 | # modify_memory :integer default(0) 21 | # modify_intuition :integer default(0) 22 | # modify_beauty :integer default(0) 23 | # equippable :boolean default(FALSE) 24 | # consummable :boolean default(FALSE) 25 | # weapon :boolean default(FALSE) 26 | # armor :boolean default(FALSE) 27 | # special_effects :json 28 | # requirements :json 29 | # created_at :datetime not null 30 | # updated_at :datetime not null 31 | # 32 | class Item < ApplicationRecord 33 | end 34 | -------------------------------------------------------------------------------- /spec/queries/campaigns/index_query_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | require_relative '../shared_context/campaigns' 3 | 4 | describe Queries::Campaigns::IndexQuery do 5 | subject(:query) { described_class.new filters: } 6 | 7 | let(:filters) do 8 | { 9 | characters: characters_filters 10 | } 11 | end 12 | let(:characters_filters) { {} } 13 | 14 | include_context 'with common database records' 15 | 16 | describe '#call' do 17 | context 'with no filters' do 18 | it 'returns a list of campaigns up to the limit size' do 19 | expect(query.call).to eq all_campaigns 20 | end 21 | end 22 | 23 | context 'with a specific time window of campaigns' do 24 | let(:filters) do 25 | { 26 | start_date_gteq: 1.day.ago, 27 | characters: characters_filters 28 | } 29 | end 30 | 31 | it 'returns only campaigns with the time window' do 32 | expect(query.call).to eq(all_campaigns - [old_campaign, older_campaign]) 33 | end 34 | end 35 | 36 | context 'with a filter for campaigns that include legendary characters' do 37 | let(:characters_filters) { { legendary: true } } 38 | 39 | it 'returns only campaigns that include a legendary character' do 40 | expect(query.call).to eq [characters_campaign] 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/domain/queries/campaigns/index_query.rb: -------------------------------------------------------------------------------- 1 | module Queries 2 | module Campaigns 3 | class IndexQuery < Queries::Base 4 | 5 | option :filters, type: ::Types::Hash.constructor { |v| v.to_h }, default: proc { {} } 6 | option :limit, type: ::Types::Integer, default: proc { 50 } 7 | option :order_by, type: ::Types::String, default: proc { 'id ASC' } 8 | 9 | def apply 10 | apply_joins 11 | builder 12 | .add(QueryBuilder::Nodes::SelectDistinct.new(initial_state, %i(id title))) 13 | .add(wheres_operator(:and, *and_filters)) 14 | .add(QueryBuilder::Nodes::OrderBy.new(order_by)) 15 | .add(QueryBuilder::Nodes::Limit.new(limit)) 16 | end 17 | 18 | def initial_state 19 | ::Campaign 20 | end 21 | 22 | private 23 | 24 | def and_filters 25 | [ 26 | QueryBuilder::Campaigns::Nodes::TitleIn.new(filters[:titles]), 27 | QueryBuilder::Campaigns::Nodes::TimeWindow.new(**time_window), 28 | QueryBuilder::Characters::Nodes::Legendary.new(characters_filters[:legendary]) 29 | ] 30 | end 31 | 32 | def characters_filters 33 | filters[:characters] || {} 34 | end 35 | 36 | def time_window 37 | { start_date_lteq: filters[:start_date_lteq], start_date_gteq: filters[:start_date_gteq] } 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/queries/shared_context/characters.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context 'with full character suite' do 2 | let(:newbie) { create(:character) } 3 | let(:spellcaster) { create(:character) } 4 | let(:flawed_ranger) { create(:character) } 5 | let(:virtuous_paladin) { create(:character) } 6 | let(:dungeoneer) { create(:character) } 7 | let(:the_legend) { create(:character, :legendary) } 8 | 9 | let!(:all_characters) do 10 | [newbie, spellcaster, flawed_ranger, virtuous_paladin, dungeoneer, the_legend] 11 | end 12 | 13 | let(:greater_spell) { create(:spell, name: 'greater healing', level: 4) } 14 | let(:lesser_spell) { create(:spell, name: 'lesser healing', level: 2) } 15 | let(:dungeoneer_feat) { create(:feat, name: 'dungeoneering') } 16 | let(:virtue) { create(:virtue, title: 'great strength') } 17 | let(:flaw) { create(:flaw, title: 'forgetful') } 18 | let(:specialization) { create(:specialization, name: 'Ranger') } 19 | 20 | let!(:characters_spell1) { CharactersSpell.create(character: spellcaster, spell: greater_spell) } 21 | let!(:characters_spell2) { CharactersSpell.create(character: the_legend, spell: lesser_spell) } 22 | let!(:characters_feat) { CharactersFeat.create(character: dungeoneer, feat: dungeoneer_feat) } 23 | let!(:characters_virtue) { CharactersVirtue.create(character: virtuous_paladin, virtue: virtue) } 24 | let!(:characters_flaw) { CharactersFlaw.create(character: flawed_ranger, flaw: flaw) } 25 | let!(:characters_specialization) { CharactersSpecialization.create(character: flawed_ranger, specialization:) } 26 | end 27 | -------------------------------------------------------------------------------- /app/domain/queries/characters/index_query.rb: -------------------------------------------------------------------------------- 1 | module Queries 2 | module Characters 3 | class IndexQuery < Queries::Base 4 | option :limit, type: ::Types::Integer, default: proc { 50 } 5 | option :order_by, type: ::Types::String, default: proc { 'name ASC' } 6 | 7 | private 8 | 9 | def apply 10 | apply_joins 11 | builder 12 | .add(wheres_operator(:and, *and_filters)) 13 | .add(QueryBuilder::Nodes::OrderBy.new(order_by)) 14 | .add(QueryBuilder::Nodes::Limit.new(limit)) 15 | end 16 | 17 | def initial_state 18 | ::Character 19 | end 20 | 21 | def and_filters 22 | [ 23 | QueryBuilder::Spells::Nodes::LevelIn.new(spells_filters[:levels]), 24 | QueryBuilder::Spells::Nodes::NameIn.new(spells_filters[:names]), 25 | QueryBuilder::Flaws::Nodes::TitleIn.new(flaws_filters[:titles]), 26 | QueryBuilder::Virtues::Nodes::TitleIn.new(virtues_filters[:titles]), 27 | QueryBuilder::Feats::Nodes::NameIn.new(feats_filters[:names]), 28 | QueryBuilder::Specializations::Nodes::NameIn.new(specializations_filters[:names]), 29 | QueryBuilder::Characters::Nodes::Legendary.new(filters[:legendary]) 30 | ] 31 | end 32 | 33 | def spells_filters 34 | filters[:spells] || {} 35 | end 36 | 37 | def flaws_filters 38 | filters[:flaws] || {} 39 | end 40 | 41 | def virtues_filters 42 | filters[:virtues] || {} 43 | end 44 | 45 | def feats_filters 46 | filters[:feats] || {} 47 | end 48 | 49 | def specializations_filters 50 | filters[:specializations] || {} 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/models/character.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: characters 4 | # 5 | # id :bigint not null, primary key 6 | # name :string not null 7 | # version :integer default(1), not null 8 | # origin_story :text 9 | # player_id :integer 10 | # campaign_id :integer 11 | # age :integer 12 | # gender :string 13 | # size :string not null 14 | # species :string not null 15 | # height :float 16 | # weight :float 17 | # experience :integer 18 | # strength :integer not null 19 | # agility :integer not null 20 | # health :integer not null 21 | # reasoning :integer not null 22 | # memory :integer not null 23 | # intuition :integer not null 24 | # beauty :integer not null 25 | # created_at :datetime not null 26 | # updated_at :datetime not null 27 | # 28 | class Character < ApplicationRecord 29 | belongs_to :campaign, dependent: :destroy, optional: true 30 | belongs_to :players, dependent: :destroy, optional: true 31 | has_many :characters_spells 32 | has_many :spells, through: :characters_spells 33 | has_many :characters_feats 34 | has_many :feats, through: :characters_feats 35 | has_many :characters_flaws 36 | has_many :flaws, through: :characters_flaws 37 | has_many :characters_virtues 38 | has_many :virtues, through: :characters_virtues 39 | has_many :characters_skills 40 | has_many :skills, through: :characters_skills 41 | has_many :characters_specializations 42 | has_many :specializations, through: :characters_specializations 43 | has_one :inventory 44 | has_many :inventories_items, through: :inventory 45 | has_many :items, through: :inventories_items 46 | end 47 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Print deprecation notices to the stderr. 37 | config.active_support.deprecation = :stderr 38 | 39 | # Raise exceptions for disallowed deprecations. 40 | config.active_support.disallowed_deprecation = :raise 41 | 42 | # Tell Active Support which deprecation messages to disallow. 43 | config.active_support.disallowed_deprecation_warnings = [] 44 | 45 | # Raises error for missing translations. 46 | # config.i18n.raise_on_missing_translations = true 47 | 48 | # Annotate rendered view with file names. 49 | # config.action_view.annotate_rendered_view_with_filenames = true 50 | end 51 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Print deprecation notices to the Rails logger. 37 | config.active_support.deprecation = :log 38 | 39 | # Raise exceptions for disallowed deprecations. 40 | config.active_support.disallowed_deprecation = :raise 41 | 42 | # Tell Active Support which deprecation messages to disallow. 43 | config.active_support.disallowed_deprecation_warnings = [] 44 | 45 | # Raise an error on page load if there are pending migrations. 46 | config.active_record.migration_error = :page_load 47 | 48 | # Highlight code that triggered database queries in logs. 49 | config.active_record.verbose_query_logs = true 50 | 51 | # Suppress logger output for asset requests. 52 | config.assets.quiet = true 53 | 54 | # Raises error for missing translations. 55 | # config.i18n.raise_on_missing_translations = true 56 | 57 | # Annotate rendered view with file names. 58 | # config.action_view.annotate_rendered_view_with_filenames = true 59 | 60 | # Uncomment if you wish to allow Action Cable access from any origin. 61 | # config.action_cable.disable_request_forgery_protection = true 62 | end 63 | -------------------------------------------------------------------------------- /spec/queries/characters/index_query_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | require_relative '../shared_context/characters' 3 | 4 | describe Queries::Characters::IndexQuery do 5 | subject(:query) { described_class.new filters: } 6 | 7 | let(:filters) do 8 | {} 9 | end 10 | 11 | include_context 'with full character suite' 12 | 13 | describe '#call' do 14 | context 'with no filters' do 15 | it 'returns an ActiveRecord Relation of characters up to the limit size' do 16 | expect(query.call).to include(*all_characters) 17 | end 18 | end 19 | 20 | context 'with spells filters' do 21 | let(:filters) do 22 | { spells: { names: ['greater healing', 'lesser healing'] } } 23 | end 24 | 25 | it 'returns only characters that have one of the spells' do 26 | expect(query.call).to contain_exactly(spellcaster, the_legend) 27 | end 28 | end 29 | 30 | context 'with flaws filters' do 31 | let(:filters) do 32 | { flaws: { titles: ['forgetful'] } } 33 | end 34 | 35 | it 'returns only characters that have one of the flaws' do 36 | expect(query.call).to contain_exactly(flawed_ranger) 37 | end 38 | end 39 | 40 | context 'with virtues filters' do 41 | let(:filters) do 42 | { virtues: { titles: ['great strength'] } } 43 | end 44 | 45 | it 'returns only characters that have one of the virtues' do 46 | expect(query.call).to contain_exactly(virtuous_paladin) 47 | end 48 | end 49 | 50 | context 'with feats filters' do 51 | let(:filters) do 52 | { feats: { names: ['dungeoneering'] } } 53 | end 54 | 55 | it 'returns only characters that have one of the feats' do 56 | expect(query.call).to contain_exactly(dungeoneer) 57 | end 58 | end 59 | 60 | context 'with specializations filters' do 61 | let(:filters) do 62 | { specializations: { names: ['Ranger'] } } 63 | end 64 | 65 | it 'returns only characters that have one of the feats' do 66 | expect(query.call).to contain_exactly(flawed_ranger) 67 | end 68 | end 69 | 70 | context 'with legendary filter' do 71 | let(:filters) do 72 | { legendary: true } 73 | end 74 | 75 | it 'returns only legendary characters' do 76 | expect(query.call).to contain_exactly(the_legend) 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | require 'spec_helper' 3 | ENV['RAILS_ENV'] ||= 'test' 4 | require_relative '../config/environment' 5 | # Prevent database truncation if the environment is production 6 | abort("The Rails environment is running in production mode!") if Rails.env.production? 7 | require 'rspec/rails' 8 | # Add additional requires below this line. Rails is not loaded until this point! 9 | 10 | # Requires supporting ruby files with custom matchers and macros, etc, in 11 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 12 | # run as spec files by default. This means that files in spec/support that end 13 | # in _spec.rb will both be required and run as specs, causing the specs to be 14 | # run twice. It is recommended that you do not name files matching this glob to 15 | # end with _spec.rb. You can configure this pattern with the --pattern 16 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 17 | # 18 | # The following line is provided for convenience purposes. It has the downside 19 | # of increasing the boot-up time by auto-requiring all files in the support 20 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 21 | # require only the support files necessary. 22 | # 23 | # Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } 24 | 25 | # Checks for pending migrations and applies them before tests are run. 26 | # If you are not using ActiveRecord, you can remove these lines. 27 | begin 28 | ActiveRecord::Migration.maintain_test_schema! 29 | rescue ActiveRecord::PendingMigrationError => e 30 | abort e.to_s.strip 31 | end 32 | RSpec.configure do |config| 33 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 34 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 35 | 36 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 37 | # examples within a transaction, remove the following line or assign false 38 | # instead of true. 39 | config.use_transactional_fixtures = true 40 | 41 | # You can uncomment this line to turn off ActiveRecord support entirely. 42 | # config.use_active_record = false 43 | 44 | # RSpec Rails can automatically mix in different behaviours to your tests 45 | # based on their file location, for example enabling you to call `get` and 46 | # `post` in specs under `spec/controllers`. 47 | # 48 | # You can disable this behaviour by removing the line below, and instead 49 | # explicitly tag your specs with their type, e.g.: 50 | # 51 | # RSpec.describe UsersController, type: :controller do 52 | # # ... 53 | # end 54 | # 55 | # The different available types are documented in the features, such as in 56 | # https://relishapp.com/rspec/rspec-rails/docs 57 | config.infer_spec_type_from_file_location! 58 | 59 | # Filter lines from Rails gems in backtraces. 60 | config.filter_rails_from_backtrace! 61 | config.include FactoryBot::Syntax::Methods 62 | # arbitrary gems may also be filtered via: 63 | # config.filter_gems_from_backtrace("gem name") 64 | end 65 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 41 | # config.force_ssl = true 42 | 43 | # Include generic and useful information about system operation, but avoid logging too much 44 | # information to avoid inadvertent exposure of personally identifiable information (PII). 45 | config.log_level = :info 46 | 47 | # Prepend all log lines with the following tags. 48 | config.log_tags = [ :request_id ] 49 | 50 | # Use a different cache store in production. 51 | # config.cache_store = :mem_cache_store 52 | 53 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 54 | # the I18n.default_locale when a translation cannot be found). 55 | config.i18n.fallbacks = true 56 | 57 | # Don't log any deprecations. 58 | config.active_support.report_deprecations = false 59 | 60 | # Use default logging formatter so that PID and timestamp are not suppressed. 61 | config.log_formatter = ::Logger::Formatter.new 62 | 63 | # Use a different logger for distributed setups. 64 | # require "syslog/logger" 65 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 66 | 67 | if ENV["RAILS_LOG_TO_STDOUT"].present? 68 | logger = ActiveSupport::Logger.new(STDOUT) 69 | logger.formatter = config.log_formatter 70 | config.logger = ActiveSupport::TaggedLogging.new(logger) 71 | end 72 | 73 | # Do not dump schema after migrations. 74 | config.active_record.dump_schema_after_migration = false 75 | end 76 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 9.3 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On macOS with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On macOS with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem "pg" 16 | # 17 | default: &default 18 | adapter: postgresql 19 | prepared_statements: false 20 | username: postgres 21 | password: postgres 22 | host: <%= ENV['DB_HOST'] || 'localhost' %> 23 | pool: <%= ENV.fetch('RAILS_MAX_THREADS',5) %> 24 | timeout: 5000 25 | 26 | development: 27 | <<: *default 28 | database: rails_2023_showcase_development 29 | 30 | # The specified database role being used to connect to postgres. 31 | # To create additional roles in postgres see `$ createuser --help`. 32 | # When left blank, postgres will use the default role. This is 33 | # the same name as the operating system user running Rails. 34 | #username: rails_2023_showcase 35 | 36 | # The password associated with the postgres role (username). 37 | #password: 38 | 39 | # Connect on a TCP socket. Omitted by default since the client uses a 40 | # domain socket that doesn't need configuration. Windows does not have 41 | # domain sockets, so uncomment these lines. 42 | #host: localhost 43 | 44 | # The TCP port the server listens on. Defaults to 5432. 45 | # If your server runs on a different port number, change accordingly. 46 | #port: 5432 47 | 48 | # Schema search path. The server defaults to $user,public 49 | #schema_search_path: myapp,sharedapp,public 50 | 51 | # Minimum log levels, in increasing order: 52 | # debug5, debug4, debug3, debug2, debug1, 53 | # log, notice, warning, error, fatal, and panic 54 | # Defaults to warning. 55 | #min_messages: notice 56 | 57 | # Warning: The database defined as "test" will be erased and 58 | # re-generated from your development database when you run "rake". 59 | # Do not set this db to the same as development or production. 60 | test: 61 | <<: *default 62 | database: rails_2023_showcase_test 63 | 64 | # As with config/credentials.yml, you never want to store sensitive information, 65 | # like your database password, in your source code. If your source code is 66 | # ever seen by anyone, they now have access to your database. 67 | # 68 | # Instead, provide the password or a full connection URL as an environment 69 | # variable when you boot the app. For example: 70 | # 71 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 72 | # 73 | # If the connection URL is provided in the special DATABASE_URL environment 74 | # variable, Rails will automatically merge its configuration values on top of 75 | # the values provided in this file. Alternatively, you can specify a connection 76 | # URL environment variable explicitly: 77 | # 78 | # production: 79 | # url: <%= ENV["MY_APP_DATABASE_URL"] %> 80 | # 81 | # Read https://guides.rubyonrails.org/configuring.html#configuring-a-database 82 | # for a full overview on how database connection configuration can be specified. 83 | # 84 | production: 85 | <<: *default 86 | database: rails_2023_showcase_production 87 | username: rails_2023_showcase 88 | password: <%= ENV["RAILS_2023_SHOWCASE_DATABASE_PASSWORD"] %> 89 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # The settings below are suggested to provide a good initial experience 48 | # with RSpec, but feel free to customize to your heart's content. 49 | =begin 50 | # This allows you to limit a spec run to individual examples or groups 51 | # you care about by tagging them with `:focus` metadata. When nothing 52 | # is tagged with `:focus`, all examples get run. RSpec also provides 53 | # aliases for `it`, `describe`, and `context` that include `:focus` 54 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 55 | config.filter_run_when_matching :focus 56 | 57 | # Allows RSpec to persist some state between runs in order to support 58 | # the `--only-failures` and `--next-failure` CLI options. We recommend 59 | # you configure your source control system to ignore this file. 60 | config.example_status_persistence_file_path = "spec/examples.txt" 61 | 62 | # Limits the available syntax to the non-monkey patched syntax that is 63 | # recommended. For more details, see: 64 | # https://relishapp.com/rspec/rspec-core/docs/configuration/zero-monkey-patching-mode 65 | config.disable_monkey_patching! 66 | 67 | # Many RSpec users commonly either run the entire suite or an individual 68 | # file, and it's useful to allow more verbose output when running an 69 | # individual spec file. 70 | if config.files_to_run.one? 71 | # Use the documentation formatter for detailed output, 72 | # unless a formatter has already been configured 73 | # (e.g. via a command-line flag). 74 | config.default_formatter = "doc" 75 | end 76 | 77 | # Print the 10 slowest examples and example groups at the 78 | # end of the spec run, to help surface which specs are running 79 | # particularly slow. 80 | config.profile_examples = 10 81 | 82 | # Run specs in random order to surface order dependencies. If you find an 83 | # order dependency and want to debug it, you can fix the order by providing 84 | # the seed, which is printed after each run. 85 | # --seed 1234 86 | config.order = :random 87 | 88 | # Seed global randomization in this process using the `--seed` CLI option. 89 | # Setting this allows you to use `--seed` to deterministically reproduce 90 | # test failures related to randomization by passing the same `--seed` value 91 | # as the one that triggered the failure. 92 | Kernel.srand config.seed 93 | =end 94 | end 95 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | size_proc = proc { %w(tiny small medium large).sample } 2 | stat_proc = proc { (8..18).to_a.sample } 3 | exp_proc = proc { [0, 0.2, 0.5, 1, 2, 3].sample * rand(10000) } 4 | measurements_proc = proc do 5 | height = 4 + (rand * 3) 6 | weight = 20 * height + (rand * 25 * height) 7 | { height:, weight: } 8 | end 9 | genders_proc = proc do 10 | (%w(male male male male female female female female) + [nil]).sample || Faker::Gender.type 11 | end 12 | 13 | 100.times do 14 | Campaign.create do |c| 15 | c.title = "#{Faker::Book.title}" 16 | c.description = Faker::Quote.fortune_cookie 17 | c.start_date = (rand * 1000).days.ago 18 | end 19 | end 20 | 21 | campaign_ids = Campaign.all.pluck(:id) 22 | 23 | 500.times do 24 | Character.create do |c| 25 | c.campaign_id = campaign_ids.sample 26 | c.name = Faker::FunnyName.name 27 | c.age = 16 + ([0, 1, 1, 1, 1, 2, 2, 3].sample * rand(20)) 28 | c.gender = genders_proc.call 29 | c.size = size_proc.call 30 | c.species = Faker::Games::DnD.race 31 | 32 | measurements = measurements_proc.call 33 | 34 | c.height = measurements[:height] 35 | c.weight = measurements[:weight] 36 | 37 | c.experience = exp_proc.call 38 | 39 | %w(strength agility health reasoning memory intuition beauty).each do |stat| 40 | num = stat_proc.call 41 | c.send("#{stat}=", num) 42 | end 43 | end 44 | end 45 | 46 | feat_name_proc = proc do 47 | adj = Faker::Adjective.positive 48 | verb = %w(fighting devling dodging exploring casting defending fleeing bartering healing speaking).sample 49 | "#{adj} #{verb}" 50 | end 51 | 52 | 30.times do 53 | Feat.create do |f| 54 | f.name = feat_name_proc.call 55 | f.description = Faker::Quote.fortune_cookie 56 | end 57 | end 58 | 59 | attributes = %w(strength agility health reasoning memory intuition beauty) 60 | 61 | modify_stat_proc = proc do |operand| 62 | operand ||= "+" 63 | 0.send(operand, rand(4)) 64 | rescue ZeroDivisionError 65 | 0 66 | end 67 | 68 | flaw_proc = proc do 69 | adj = Faker::Adjective.negative 70 | attr = attributes.sample 71 | mod = modify_stat_proc.call(:-) 72 | { attr:, title: "#{adj} #{attr}", mod:} 73 | end 74 | 75 | 100.times do 76 | Flaw.create do |f| 77 | attrs = flaw_proc.call 78 | mod = attrs[:mod] 79 | attr = attrs[:attr] 80 | 81 | f.title = attrs[:title] 82 | f.send("modify_#{attr}=", mod) 83 | f.description = Faker::Quote.fortune_cookie 84 | end 85 | end 86 | 87 | virtue_proc = proc do 88 | adj = Faker::Adjective.positive 89 | attr = attributes.sample 90 | mod = modify_stat_proc.call(:+) 91 | { attr:, title: "#{adj} #{attr}", mod:} 92 | end 93 | 94 | 100.times do 95 | Virtue.create do |f| 96 | attrs = virtue_proc.call 97 | mod = attrs[:mod] 98 | attr = attrs[:attr] 99 | 100 | f.title = attrs[:title] 101 | f.send("modify_#{attr}=", mod) 102 | f.description = Faker::Quote.fortune_cookie 103 | end 104 | end 105 | 106 | flaw_ids = Flaw.all.pluck(:id) 107 | feat_ids = Feat.all.pluck(:id) 108 | virtue_ids = Virtue.all.pluck(:id) 109 | 110 | Character.all.find_each do |character| 111 | character_id = character.id 112 | CharactersFeat.create(character_id:, feat_id: feat_ids.sample) 113 | CharactersVirtue.create(character_id:, virtue_id: virtue_ids.sample) 114 | CharactersFlaw.create(character_id:, flaw_id: flaw_ids.sample) 115 | end 116 | 117 | adj1 = %w(lesser standard plus_one plus_two greater greatest) 118 | adj2 = %w(bronze iron gold mithril glass dragonscale silver) 119 | potion_names = %w(invisibility strength agility health memory intuition reasoning beauty healing resistance dodging) 120 | armor_names = %w(helmet shield breastplate chainmail leggins greaves) 121 | weapon_names = %w(sword spear longbow shortbow crossbow greatsword dagger axe greataxe lance) 122 | 123 | adj1.each do |modifier| 124 | adj2.each do |material| 125 | weapon_names.each do |type| 126 | name = "#{modifier} #{material} #{type}" 127 | Item.create(name:, equippable: true, weapon: true) 128 | end 129 | end 130 | end 131 | 132 | adj1.each do |modifier| 133 | adj2.each do |material| 134 | armor_names.each do |type| 135 | name = "#{modifier} #{material} #{type}" 136 | Item.create(name:, equippable: true, armor: true) 137 | end 138 | end 139 | end 140 | 141 | adj1.each do |modifier| 142 | potion_names.each do |type| 143 | name = "#{modifier} potion of #{type}" 144 | Item.create(name:, consummable: true) 145 | end 146 | end 147 | 148 | weapons_ids = Item.where(weapon: true).pluck(:id) 149 | armors_ids = Item.where(armor: true).pluck(:id) 150 | potions_ids = Item.where(consummable: true).pluck(:id) 151 | 152 | spell_names = %w(warding healing summoning transformation fire ice lightning illusion) 153 | adj1 = %w(lesser minor power greater masterful legendary) 154 | 155 | spell_names.each do |type| 156 | adj1.each.with_index(1) do |mod, idx| 157 | name = "#{mod} spell of #{type}" 158 | Spell.create(name: name, description: name, level: idx) 159 | end 160 | end 161 | 162 | spell_ids = Spell.all.pluck(:id) 163 | 164 | Character.all.find_each do |c| 165 | character_id = c.id 166 | 167 | CharactersSpell.create(spell_id: spell_ids.sample, character_id:) 168 | CharactersSpell.find_or_create_by(spell_id: spell_ids.sample, character_id:) 169 | 170 | inventory = Inventory.create(character_id:) 171 | inventory_id = inventory.id 172 | 173 | InventoriesItem.create(inventory_id:, item_id: weapons_ids.sample) 174 | InventoriesItem.create(inventory_id:, item_id: armors_ids.sample) 175 | InventoriesItem.find_or_create_by(inventory_id:, item_id: armors_ids.sample) 176 | InventoriesItem.create(inventory_id:, item_id: weapons_ids.sample) 177 | InventoriesItem.create(inventory_id:, item_id: potions_ids.sample) 178 | InventoriesItem.create(inventory_id:, item_id: potions_ids.sample) 179 | end 180 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (7.0.4.3) 5 | actionpack (= 7.0.4.3) 6 | activesupport (= 7.0.4.3) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | actionmailbox (7.0.4.3) 10 | actionpack (= 7.0.4.3) 11 | activejob (= 7.0.4.3) 12 | activerecord (= 7.0.4.3) 13 | activestorage (= 7.0.4.3) 14 | activesupport (= 7.0.4.3) 15 | mail (>= 2.7.1) 16 | net-imap 17 | net-pop 18 | net-smtp 19 | actionmailer (7.0.4.3) 20 | actionpack (= 7.0.4.3) 21 | actionview (= 7.0.4.3) 22 | activejob (= 7.0.4.3) 23 | activesupport (= 7.0.4.3) 24 | mail (~> 2.5, >= 2.5.4) 25 | net-imap 26 | net-pop 27 | net-smtp 28 | rails-dom-testing (~> 2.0) 29 | actionpack (7.0.4.3) 30 | actionview (= 7.0.4.3) 31 | activesupport (= 7.0.4.3) 32 | rack (~> 2.0, >= 2.2.0) 33 | rack-test (>= 0.6.3) 34 | rails-dom-testing (~> 2.0) 35 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 36 | actiontext (7.0.4.3) 37 | actionpack (= 7.0.4.3) 38 | activerecord (= 7.0.4.3) 39 | activestorage (= 7.0.4.3) 40 | activesupport (= 7.0.4.3) 41 | globalid (>= 0.6.0) 42 | nokogiri (>= 1.8.5) 43 | actionview (7.0.4.3) 44 | activesupport (= 7.0.4.3) 45 | builder (~> 3.1) 46 | erubi (~> 1.4) 47 | rails-dom-testing (~> 2.0) 48 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 49 | activejob (7.0.4.3) 50 | activesupport (= 7.0.4.3) 51 | globalid (>= 0.3.6) 52 | activemodel (7.0.4.3) 53 | activesupport (= 7.0.4.3) 54 | activerecord (7.0.4.3) 55 | activemodel (= 7.0.4.3) 56 | activesupport (= 7.0.4.3) 57 | activestorage (7.0.4.3) 58 | actionpack (= 7.0.4.3) 59 | activejob (= 7.0.4.3) 60 | activerecord (= 7.0.4.3) 61 | activesupport (= 7.0.4.3) 62 | marcel (~> 1.0) 63 | mini_mime (>= 1.1.0) 64 | activesupport (7.0.4.3) 65 | concurrent-ruby (~> 1.0, >= 1.0.2) 66 | i18n (>= 1.6, < 2) 67 | minitest (>= 5.1) 68 | tzinfo (~> 2.0) 69 | annotate (3.2.0) 70 | activerecord (>= 3.2, < 8.0) 71 | rake (>= 10.4, < 14.0) 72 | builder (3.2.4) 73 | coderay (1.1.3) 74 | concurrent-ruby (1.2.2) 75 | crass (1.0.6) 76 | date (3.3.3) 77 | debug (1.7.2) 78 | irb (>= 1.5.0) 79 | reline (>= 0.3.1) 80 | diff-lcs (1.5.0) 81 | dry-container (0.11.0) 82 | concurrent-ruby (~> 1.0) 83 | dry-core (1.0.0) 84 | concurrent-ruby (~> 1.0) 85 | zeitwerk (~> 2.6) 86 | dry-inflector (1.0.0) 87 | dry-initializer (3.1.1) 88 | dry-initializer-rails (3.1.1) 89 | dry-initializer (>= 2.4, < 4) 90 | rails (> 3.0) 91 | dry-logic (1.5.0) 92 | concurrent-ruby (~> 1.0) 93 | dry-core (~> 1.0, < 2) 94 | zeitwerk (~> 2.6) 95 | dry-types (1.7.1) 96 | concurrent-ruby (~> 1.0) 97 | dry-core (~> 1.0) 98 | dry-inflector (~> 1.0) 99 | dry-logic (~> 1.4) 100 | zeitwerk (~> 2.6) 101 | erubi (1.12.0) 102 | factory_bot (6.2.1) 103 | activesupport (>= 5.0.0) 104 | factory_bot_rails (6.2.0) 105 | factory_bot (~> 6.2.0) 106 | railties (>= 5.0.0) 107 | faker (2.19.0) 108 | i18n (>= 1.6, < 2) 109 | globalid (1.1.0) 110 | activesupport (>= 5.0) 111 | i18n (1.12.0) 112 | concurrent-ruby (~> 1.0) 113 | io-console (0.6.0) 114 | irb (1.6.4) 115 | reline (>= 0.3.0) 116 | loofah (2.20.0) 117 | crass (~> 1.0.2) 118 | nokogiri (>= 1.5.9) 119 | mail (2.8.1) 120 | mini_mime (>= 0.1.1) 121 | net-imap 122 | net-pop 123 | net-smtp 124 | marcel (1.0.2) 125 | method_source (1.0.0) 126 | mini_mime (1.1.2) 127 | minitest (5.18.0) 128 | net-imap (0.3.4) 129 | date 130 | net-protocol 131 | net-pop (0.1.2) 132 | net-protocol 133 | net-protocol (0.2.1) 134 | timeout 135 | net-smtp (0.3.3) 136 | net-protocol 137 | nio4r (2.5.9) 138 | nokogiri (1.14.3-x86_64-darwin) 139 | racc (~> 1.4) 140 | pg (1.4.6) 141 | pry (0.14.2) 142 | coderay (~> 1.1) 143 | method_source (~> 1.0) 144 | pry-rails (0.3.9) 145 | pry (>= 0.10.4) 146 | puma (5.6.5) 147 | nio4r (~> 2.0) 148 | racc (1.6.2) 149 | rack (2.2.6.4) 150 | rack-test (2.1.0) 151 | rack (>= 1.3) 152 | rails (7.0.4.3) 153 | actioncable (= 7.0.4.3) 154 | actionmailbox (= 7.0.4.3) 155 | actionmailer (= 7.0.4.3) 156 | actionpack (= 7.0.4.3) 157 | actiontext (= 7.0.4.3) 158 | actionview (= 7.0.4.3) 159 | activejob (= 7.0.4.3) 160 | activemodel (= 7.0.4.3) 161 | activerecord (= 7.0.4.3) 162 | activestorage (= 7.0.4.3) 163 | activesupport (= 7.0.4.3) 164 | bundler (>= 1.15.0) 165 | railties (= 7.0.4.3) 166 | rails-dom-testing (2.0.3) 167 | activesupport (>= 4.2.0) 168 | nokogiri (>= 1.6) 169 | rails-html-sanitizer (1.5.0) 170 | loofah (~> 2.19, >= 2.19.1) 171 | railties (7.0.4.3) 172 | actionpack (= 7.0.4.3) 173 | activesupport (= 7.0.4.3) 174 | method_source 175 | rake (>= 12.2) 176 | thor (~> 1.0) 177 | zeitwerk (~> 2.5) 178 | rake (13.0.6) 179 | reline (0.3.3) 180 | io-console (~> 0.5) 181 | rspec-core (3.12.1) 182 | rspec-support (~> 3.12.0) 183 | rspec-expectations (3.12.2) 184 | diff-lcs (>= 1.2.0, < 2.0) 185 | rspec-support (~> 3.12.0) 186 | rspec-mocks (3.12.5) 187 | diff-lcs (>= 1.2.0, < 2.0) 188 | rspec-support (~> 3.12.0) 189 | rspec-rails (6.0.1) 190 | actionpack (>= 6.1) 191 | activesupport (>= 6.1) 192 | railties (>= 6.1) 193 | rspec-core (~> 3.11) 194 | rspec-expectations (~> 3.11) 195 | rspec-mocks (~> 3.11) 196 | rspec-support (~> 3.11) 197 | rspec-support (3.12.0) 198 | sprockets (4.2.0) 199 | concurrent-ruby (~> 1.0) 200 | rack (>= 2.2.4, < 4) 201 | sprockets-rails (3.4.2) 202 | actionpack (>= 5.2) 203 | activesupport (>= 5.2) 204 | sprockets (>= 3.0.0) 205 | thor (1.2.1) 206 | timeout (0.3.2) 207 | tzinfo (2.0.6) 208 | concurrent-ruby (~> 1.0) 209 | websocket-driver (0.7.5) 210 | websocket-extensions (>= 0.1.0) 211 | websocket-extensions (0.1.5) 212 | zeitwerk (2.6.7) 213 | 214 | PLATFORMS 215 | x86_64-darwin-22 216 | 217 | DEPENDENCIES 218 | annotate 219 | debug 220 | dry-container 221 | dry-initializer-rails 222 | dry-types 223 | factory_bot_rails 224 | faker 225 | pg (~> 1.1) 226 | pry 227 | pry-rails 228 | puma (~> 5.0) 229 | rails (~> 7.0.4, >= 7.0.4.3) 230 | rspec-rails 231 | sprockets-rails 232 | tzinfo-data 233 | 234 | RUBY VERSION 235 | ruby 3.2.0p0 236 | 237 | BUNDLED WITH 238 | 2.4.3 239 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | To use this repo: 4 | 5 | 1. Fork your own copy of this repo and clone it. Change directories in your terminal to the project name. 6 | 2. Run `bundle install` in your terminal. (Note: Ruby and is a dependency. Make sure you have version 3.2.0 installed) 7 | 3. Run `rails db:create` in your terminal to create your development and test database. 8 | 4. Run `rails db:migrate` to construct your database schema. 9 | 5. Run `rails db:seed` to seed your database with records 10 | (Note: this may take a while. If it is taking too long then consider editing the seeds file to create fewer records) 11 | 6. Run `rspec spec` to see test results. 12 | 13 | # Overview 14 | 15 | This repository is intended to demonstrate G2's query builder / visitor pattern ("builder pattern"). This builder 16 | pattern sits atop ActiveRecord as a higher level abstraction that incorporates domain specific logic. With the builder 17 | pattern it is possible to gracefully construct composable queries that deal well with conditional branches and are 18 | highly modular, and thus able to deal gracefully with extensions or modifications in response to new features or 19 | changes to business logic. The remainder of this readme serves as an explanation of the builder pattern and how to use 20 | it. 21 | 22 | ## Background and Database Schema 23 | 24 | We have deliberately left out a frontend to this as the builder pattern is implementation agnostic: it can serve any 25 | frontend that can make calls to a Rails backend. G2 uses graphql with this pattern but this is not required. 26 | 27 | We have left our demonstration schema relatively simple (but not too simple!) to make our queries easier to understand. 28 | The details are not very important; it is sufficient to know that this follows a 'star schema' pattern with characters 29 | as the center table connecting to a number of associated tables, including feats, flaws, spells, specializations, 30 | campaigns, and virtues, to name a few. This resembles a character management system for classic tabletop role playing 31 | games. 32 | 33 | Many software engineers love RPGs and will appreciate the schema, but if it seems unfamiliar to you then fear not! 34 | Our schema is for demonstration purposes only and it is not necessary to understand the nuances of RPGs to grasp 35 | the builder pattern this repo demonstrates. 36 | 37 | ## Query classes 38 | 39 | The best way to learn the builder pattern is to play with specific examples. Find `Queries::Characters::IndexQuery` 40 | and read it over. The specs are also a good source of documentation. 41 | Run `rspec spec/queries/characters/index_query_spec.rb` in your terminal to see the test output. 42 | 43 | The naming of the class gives us a hint at its purpose: it runs a query that returns a list of characters (specifically, 44 | an ActiveRecord Relation). The specific characters returned will depend on the filters passed in. 45 | 46 | Our class inherits some of its behavior from its base class `Queries::Base`. The builder pattern is object oriented 47 | and follows all of the usual principles of good OOP design. It uses inheritance in accordance with the Liskov principle: 48 | all query classes follow the same basic interface where their public method is #call, which returns the results of our 49 | query. The implementation details of the #call method are left up to the descendent class, but all of them add nodes to 50 | their builder and delegate the building of the query to the builder. 51 | 52 | The builder is injected as a dependency into the query class, and uses `QueryBuilder::Builders::Default` by default. 53 | This default can be overriden with a different builder that uses its own algorithm, and this is thus one of the many 54 | places our query classes maintain their composability. More on the builder later. 55 | 56 | The query class takes another input, filters. These can be strongly typed but that is an implementation detail. At 57 | its core the filters are just a nested hash that give the conditions the user has demanded of our query. Some classes 58 | take other inputs, including order and limit parameters commonly. But the builder and filters are the most important 59 | inputs for the builder pattern. 60 | 61 | We can see in `Queries::Characters::IndexQuery` that we are adding nodes to the builder and that this builder will 62 | perform some operation on them. But how does `IndexQuery` know what nodes to add in response to the filters it is given? 63 | Where is the conditional logic in this class? IndexQuery does not know what nodes to add and does not have any 64 | conditional branching in it. Those tasks are delegated elsewhere, and that is the beauty of it! 65 | 66 | The query classes can be thought of as providing the "raw material" of the query. They pass all of the nodes that the 67 | builder *might* need to construct the query. Unnecessary nodes will be eliminated when the builder visits each node with 68 | its accumulated state, and the node will decide if is to be included based on its state and the filters passed to it. 69 | 70 | Our query class is thus left free to tell a story in the nodes it contains. It is also open to easy extension: if our 71 | query needs another conditional branch or a join to another table then we can simply add the new node(s) needed, 72 | confident that the nodes and builder will handle the logic. Wrapping our various logic and clauses in Node classes also 73 | makes our code easily maintainable: do not underestimate the advantage of searchability of named classes in an editor 74 | and the ease of modifying a node if a hotfix or a change in business logic warrants it. 75 | 76 | ## Builders 77 | 78 | We next turn our attention to the builders. Find `QueryBuilder::Builders::Default` and read over this class. Its 79 | public interface is inherited from `QueryBuilder::Builders::Base`. The builder stores two crucial pieces of state: 80 | the `initial_state` and the `nodes`. Our initial_state is the head of our ActiveRecord query and is passed in from 81 | the query class. And `nodes` is an array of typed nodes where the type is specified in `base_node_class`. Our builder 82 | starts with the `nodes` array empty and has nodes added to it through the `#add` method. 83 | 84 | The query class will call `#build` on the builder once it has added all of the nodes. And the build method is where 85 | things really get interesting. Our central algorithm is revealed: the builder uses `reduce`. And it does so in unique 86 | fashion: in addition to accumulating state it eliminates invalid nodes and extracts clauses by `visiting` each node. 87 | The logic is thus mostly delegated to nodes: each node decides if it is valid based on its own logic and the state it 88 | receives; the node also decides what to do with the state (accumulated ActiveRecord query) it receives. The `builder 89 | pattern` is thus also a `visitor pattern` in that a builder visits its nodes. 90 | 91 | ## Nodes 92 | 93 | The term `nodes` will be familiar to most programmers. At its most simple a node is simply a data structure that is part 94 | of a larger link or tree structure (as in an abstract syntax tree or a query). That is also true of nodes in the builder 95 | pattern with the crucial distinction that they can also encapsulate higher domain logic and abstractions. Nodes in the 96 | builder pattern are thus not data primitives; they are named classes that hold state, can contain logic, and either 97 | resolve to an ActiveRecord clause or contain other nodes. 98 | 99 | We can start with a simpler example, `QueryBuilder::Campaigns::Nodes::TitleIn`. The naming of this class describes its 100 | purpose: it concerns the `campaigns` table, and the `TitleIn` class name makes us think of a `where title in _` clause. 101 | It takes one parameter when initialized: `titles`. These `titles` may or may not be present in filters, and thus this 102 | node exposes a `#valid?` method that checks if titles are present. Its other public method is more subtle. When a 103 | builder "visits" a node it typically checks if it is valid and calls `#accept` on the node, passing it accumulated 104 | state. In this particular node the `#accept` method simply merges a clause into the ActiveRecord query. 105 | Its clause is, unsurprisingly, `Campaign.where(title: titles)`. 106 | 107 | This node is fairly typical of the builder pattern: it defines the conditions where it is valid, accepts a filter 108 | param ("titles" in this case), and merges a clause when visited by the builder. 109 | 110 | Some nodes are not tied to a specific table but are excellent tools for use in many queries. See the common node 111 | `QueryBuilder::Nodes::Joins::InnerJoin` for a good example. 112 | 113 | ### Nested Nodes 114 | 115 | Special attention should be paid to another variety of node, however, and these are the nodes that nest other nodes. Two 116 | of these are common tools that are quite useful: `QueryBuilder::Nodes::Wheres::And` and its sibling `Or` class in the 117 | same module. These nodes can nest nodes that resolve to clauses contained in `where and` or `where or` clauses, 118 | respectively. 119 | 120 | One of the great advantages of nested nodes is the ability to group higher level domain specific logic that are often 121 | key elements of our business. And the `QueryBuilder::Characters::Nodes::Legendary` node is a perfect illustration of 122 | this. Its story is that our (hypothetical) product manager knows that our clients highly value being able to search for 123 | characters of "legendary status". These characters have reached a certain threshold of experience and progression in one 124 | of their key stats. 125 | 126 | The `Legendary` node gracefully handles the nested `where` clauses this requires, which include both `or` and `and` 127 | operators. 128 | 129 | As a consequence our domain logic for legendary characters is: 130 | 1. well documented in our codebase 131 | 2. easily searchable 132 | 3. reusabled in other queries 133 | 4. maintainable: it can be modified, if necessary, and ensure that all queries using it follow the same domain logic. 134 | 135 | The last point is one of the main necessities that inspired our builder pattern invention: in the past we found that 136 | working with lower level abstractions, such as SQL or vanilla ActiveRecord, often led to painful regressions as it 137 | was quite difficult to find where common logic was used in different queries. 138 | 139 | ## Putting it all together 140 | 141 | The builder pattern thus uses 3 main objects: queries, builders, and nodes. Queries are the public interface with the 142 | rest of our app that desires output for a query. Queries accept filters and encapsulate the raw material (nodes) of 143 | our query. They add nodes to their builder. 144 | 145 | The builder accumulates nodes and performs a reduce algorithm on its complete collection of nodes; invalid nodes that 146 | do not meet filtering criteria are eliminated, and state is accumulated through the visiting of nodes. 147 | 148 | Nodes determine if they are valid given filter params and state passed to them; a valid node produces a clause merged 149 | to the ActiveRecord query or calls on nested nodes to produce clauses. Nodes can thus be nested ad infinitum if needed. 150 | 151 | Together these domain objects give us a builder pattern that can compose queries of any needed complexity while keeping 152 | our code clean, dry, and maintainable. The examples in this repo are relatively simple for learning purposes. We at G2 153 | have built much more complex queries and have found that this pattern has stood the test of time. 154 | 155 | We hope you enjoy this repo and find the inspiration to make this builder pattern your own. 156 | 157 | With kind regards, 158 | 159 | Justin Daniel and the G2 engineering team 160 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.0].define(version: 2023_04_14_172406) do 14 | # These are extensions that must be enabled in order to support this database 15 | enable_extension "plpgsql" 16 | 17 | create_table "campaigns", force: :cascade do |t| 18 | t.string "title", null: false 19 | t.text "description" 20 | t.datetime "start_date", null: false 21 | t.datetime "created_at", null: false 22 | t.datetime "updated_at", null: false 23 | t.index ["title"], name: "index_campaigns_on_title" 24 | end 25 | 26 | create_table "campaigns_players", force: :cascade do |t| 27 | t.integer "campaign_id", null: false 28 | t.integer "player_id", null: false 29 | t.datetime "created_at", null: false 30 | t.datetime "updated_at", null: false 31 | t.index ["campaign_id", "player_id"], name: "index_campaigns_players_on_unique", unique: true 32 | t.index ["campaign_id"], name: "index_campaigns_players_on_campaign_id" 33 | t.index ["player_id"], name: "index_campaigns_players_on_player_id" 34 | end 35 | 36 | create_table "characters", force: :cascade do |t| 37 | t.string "name", null: false 38 | t.integer "version", default: 1, null: false 39 | t.text "origin_story" 40 | t.integer "player_id" 41 | t.integer "campaign_id" 42 | t.integer "age" 43 | t.string "gender" 44 | t.string "size", null: false 45 | t.string "species", null: false 46 | t.float "height" 47 | t.float "weight" 48 | t.integer "experience" 49 | t.integer "strength", null: false 50 | t.integer "agility", null: false 51 | t.integer "health", null: false 52 | t.integer "reasoning", null: false 53 | t.integer "memory", null: false 54 | t.integer "intuition", null: false 55 | t.integer "beauty", null: false 56 | t.datetime "created_at", null: false 57 | t.datetime "updated_at", null: false 58 | t.index ["campaign_id"], name: "index_characters_on_campaign_id" 59 | t.index ["player_id"], name: "index_characters_on_player_id" 60 | end 61 | 62 | create_table "characters_feats", force: :cascade do |t| 63 | t.integer "feat_id", null: false 64 | t.integer "character_id", null: false 65 | t.text "backstory" 66 | t.json "modifications" 67 | t.datetime "created_at", null: false 68 | t.datetime "updated_at", null: false 69 | t.index ["character_id"], name: "index_character_feats_on_character_id" 70 | t.index ["feat_id"], name: "index_character_feats_on_feat_id" 71 | end 72 | 73 | create_table "characters_flaws", force: :cascade do |t| 74 | t.integer "character_id", null: false 75 | t.integer "flaw_id", null: false 76 | t.integer "permanence", default: 3 77 | t.text "backstory" 78 | t.json "modifications" 79 | t.datetime "created_at", null: false 80 | t.datetime "updated_at", null: false 81 | t.index ["character_id"], name: "index_character_flaws_on_character_id" 82 | t.index ["flaw_id"], name: "index_character_flaws_on_flaw_id" 83 | end 84 | 85 | create_table "characters_skills", force: :cascade do |t| 86 | t.integer "character_id", null: false 87 | t.integer "skill_id", null: false 88 | t.integer "trained_level" 89 | t.text "backstory" 90 | t.json "modifications" 91 | t.datetime "created_at", null: false 92 | t.datetime "updated_at", null: false 93 | t.index ["character_id"], name: "index_character_skills_on_character_id" 94 | t.index ["skill_id"], name: "index_character_skills_on_skill_id" 95 | end 96 | 97 | create_table "characters_specializations", force: :cascade do |t| 98 | t.integer "character_id", null: false 99 | t.integer "specialization_id", null: false 100 | t.integer "trained_levels", default: 1, null: false 101 | t.text "backstory" 102 | t.json "modifications" 103 | t.index ["character_id", "specialization_id"], name: "index_char_spec_on_unique_fields", unique: true 104 | t.index ["specialization_id"], name: "index_char_spec_on_spec_id" 105 | end 106 | 107 | create_table "characters_spells", force: :cascade do |t| 108 | t.integer "character_id", null: false 109 | t.integer "spell_id", null: false 110 | t.text "backstory" 111 | t.json "modifications" 112 | t.index ["character_id", "spell_id"], name: "index_char_spells_on_unique_fields", unique: true 113 | t.index ["spell_id"], name: "index_char_spells_on_spell_id" 114 | end 115 | 116 | create_table "characters_virtues", force: :cascade do |t| 117 | t.integer "character_id", null: false 118 | t.integer "virtue_id", null: false 119 | t.integer "permanence", default: 3 120 | t.text "backstory" 121 | t.json "modifications" 122 | t.datetime "created_at", null: false 123 | t.datetime "updated_at", null: false 124 | t.index ["character_id"], name: "index_character_virtues_on_character_id" 125 | t.index ["virtue_id"], name: "index_character_virtues_on_virtue_id" 126 | end 127 | 128 | create_table "feats", force: :cascade do |t| 129 | t.string "name", null: false 130 | t.text "description", null: false 131 | t.integer "modify_ac", default: 0 132 | t.integer "modify_hp", default: 0 133 | t.integer "modify_age", default: 0 134 | t.integer "modify_stamina", default: 0 135 | t.integer "modify_strength", default: 0 136 | t.integer "modify_agility", default: 0 137 | t.integer "modify_health", default: 0 138 | t.integer "modify_reasoning", default: 0 139 | t.integer "modify_memory", default: 0 140 | t.integer "modify_intuition", default: 0 141 | t.integer "modify_beauty", default: 0 142 | t.datetime "created_at", null: false 143 | t.datetime "updated_at", null: false 144 | end 145 | 146 | create_table "flaws", force: :cascade do |t| 147 | t.string "title", null: false 148 | t.text "description", null: false 149 | t.string "magnitude" 150 | t.integer "modify_ac", default: 0 151 | t.integer "modify_hp", default: 0 152 | t.integer "modify_age", default: 0 153 | t.integer "modify_stamina", default: 0 154 | t.integer "modify_strength", default: 0 155 | t.integer "modify_agility", default: 0 156 | t.integer "modify_health", default: 0 157 | t.integer "modify_reasoning", default: 0 158 | t.integer "modify_memory", default: 0 159 | t.integer "modify_intuition", default: 0 160 | t.integer "modify_beauty", default: 0 161 | t.json "special_effects" 162 | t.datetime "created_at", null: false 163 | t.datetime "updated_at", null: false 164 | end 165 | 166 | create_table "inventories", force: :cascade do |t| 167 | t.integer "character_id" 168 | t.bigint "resource_id" 169 | t.string "resource_type" 170 | t.string "title" 171 | t.text "description" 172 | t.float "weight_limit" 173 | t.integer "item_limit" 174 | t.json "special_effects" 175 | t.datetime "created_at", null: false 176 | t.datetime "updated_at", null: false 177 | t.index ["character_id"], name: "index_inventories_on_character_id" 178 | end 179 | 180 | create_table "inventories_items", force: :cascade do |t| 181 | t.integer "inventory_id", null: false 182 | t.integer "item_id", null: false 183 | t.boolean "equipped", default: false 184 | t.integer "condition", default: 100 185 | t.json "modifications" 186 | t.decimal "price" 187 | t.string "price_unit", default: "gp" 188 | t.float "weight" 189 | t.datetime "created_at", null: false 190 | t.datetime "updated_at", null: false 191 | t.index ["inventory_id"], name: "index_inventory_items_on_inventory_id" 192 | t.index ["item_id"], name: "index_inventory_items_on_item_id" 193 | end 194 | 195 | create_table "items", force: :cascade do |t| 196 | t.string "name", null: false 197 | t.text "description" 198 | t.integer "hardness" 199 | t.integer "durability" 200 | t.float "weight" 201 | t.string "size" 202 | t.integer "modify_ac", default: 0 203 | t.integer "modify_hp", default: 0 204 | t.integer "modify_age", default: 0 205 | t.integer "modify_stamina", default: 0 206 | t.integer "modify_strength", default: 0 207 | t.integer "modify_agility", default: 0 208 | t.integer "modify_health", default: 0 209 | t.integer "modify_reasoning", default: 0 210 | t.integer "modify_memory", default: 0 211 | t.integer "modify_intuition", default: 0 212 | t.integer "modify_beauty", default: 0 213 | t.boolean "equippable", default: false 214 | t.boolean "consummable", default: false 215 | t.boolean "weapon", default: false 216 | t.boolean "armor", default: false 217 | t.json "special_effects" 218 | t.json "requirements" 219 | t.datetime "created_at", null: false 220 | t.datetime "updated_at", null: false 221 | end 222 | 223 | create_table "players", force: :cascade do |t| 224 | t.string "first_name" 225 | t.string "last_name" 226 | t.string "middle_name" 227 | t.string "preferred_name" 228 | t.string "pronouns" 229 | t.string "username", null: false 230 | t.string "email", null: false 231 | t.datetime "created_at", null: false 232 | t.datetime "updated_at", null: false 233 | end 234 | 235 | create_table "skills", force: :cascade do |t| 236 | t.string "name", null: false 237 | t.text "description", null: false 238 | t.boolean "core" 239 | t.string "primary_stat" 240 | t.string "secondary_stat" 241 | t.string "tertiary_stat" 242 | t.json "requirements" 243 | t.datetime "created_at", null: false 244 | t.datetime "updated_at", null: false 245 | end 246 | 247 | create_table "specializations", force: :cascade do |t| 248 | t.string "name", null: false 249 | t.text "description", null: false 250 | t.json "properties" 251 | t.datetime "created_at", null: false 252 | t.datetime "updated_at", null: false 253 | end 254 | 255 | create_table "spells", force: :cascade do |t| 256 | t.string "name", null: false 257 | t.text "description", null: false 258 | t.integer "level", default: 1, null: false 259 | t.json "effects" 260 | t.datetime "created_at", null: false 261 | t.datetime "updated_at", null: false 262 | end 263 | 264 | create_table "virtues", force: :cascade do |t| 265 | t.string "title", null: false 266 | t.text "description", null: false 267 | t.string "magnitude" 268 | t.integer "modify_ac", default: 0 269 | t.integer "modify_hp", default: 0 270 | t.integer "modify_age", default: 0 271 | t.integer "modify_stamina", default: 0 272 | t.integer "modify_strength", default: 0 273 | t.integer "modify_agility", default: 0 274 | t.integer "modify_health", default: 0 275 | t.integer "modify_reasoning", default: 0 276 | t.integer "modify_memory", default: 0 277 | t.integer "modify_intuition", default: 0 278 | t.integer "modify_beauty", default: 0 279 | t.json "special_effects" 280 | t.datetime "created_at", null: false 281 | t.datetime "updated_at", null: false 282 | end 283 | 284 | add_foreign_key "campaigns_players", "campaigns" 285 | add_foreign_key "campaigns_players", "players" 286 | add_foreign_key "characters", "campaigns" 287 | add_foreign_key "characters", "players" 288 | add_foreign_key "characters_feats", "characters" 289 | add_foreign_key "characters_feats", "feats" 290 | add_foreign_key "characters_flaws", "characters" 291 | add_foreign_key "characters_flaws", "flaws" 292 | add_foreign_key "characters_skills", "characters" 293 | add_foreign_key "characters_skills", "skills" 294 | add_foreign_key "characters_specializations", "characters" 295 | add_foreign_key "characters_specializations", "specializations" 296 | add_foreign_key "characters_spells", "characters" 297 | add_foreign_key "characters_spells", "spells" 298 | add_foreign_key "characters_virtues", "characters" 299 | add_foreign_key "characters_virtues", "virtues" 300 | add_foreign_key "inventories", "characters" 301 | add_foreign_key "inventories_items", "inventories" 302 | add_foreign_key "inventories_items", "items" 303 | end 304 | -------------------------------------------------------------------------------- /db/migrate/20230414172406_create_starting_schema.rb: -------------------------------------------------------------------------------- 1 | class CreateStartingSchema < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table 'campaigns', force: :cascade do |t| 4 | t.string 'title', null: false 5 | t.text 'description' 6 | t.datetime 'start_date', precision: 6, null: false 7 | t.datetime 'created_at', precision: 6, null: false 8 | t.datetime 'updated_at', precision: 6, null: false 9 | t.index 'title' 10 | end 11 | 12 | create_table 'players', force: :cascade do |t| 13 | t.string 'first_name' 14 | t.string 'last_name' 15 | t.string 'middle_name' 16 | t.string 'preferred_name' 17 | t.string 'pronouns' 18 | t.string 'username', null: false 19 | t.string 'email', null: false 20 | t.datetime 'created_at', precision: 6, null: false 21 | t.datetime 'updated_at', precision: 6, null: false 22 | end 23 | 24 | create_table 'campaigns_players', force: :cascade do |t| 25 | t.integer 'campaign_id', null: false 26 | t.integer 'player_id', null: false 27 | t.datetime 'created_at', precision: 6, null: false 28 | t.datetime 'updated_at', precision: 6, null: false 29 | t.index ['campaign_id'], name: 'index_campaigns_players_on_campaign_id' 30 | t.index ['player_id'], name: 'index_campaigns_players_on_player_id' 31 | t.index %w[campaign_id player_id], unique: true, name: 'index_campaigns_players_on_unique' 32 | end 33 | 34 | create_table 'characters', force: :cascade do |t| 35 | t.string 'name', null: false 36 | t.integer 'version', default: 1, null: false 37 | t.text 'origin_story' 38 | t.integer 'player_id' 39 | t.integer 'campaign_id' 40 | t.integer 'age' 41 | t.string 'gender' 42 | t.string 'size', null: false 43 | t.string 'species', null: false 44 | t.float 'height' 45 | t.float 'weight' 46 | t.integer 'experience' 47 | t.integer 'strength', null: false 48 | t.integer 'agility', null: false 49 | t.integer 'health', null: false 50 | t.integer 'reasoning', null: false 51 | t.integer 'memory', null: false 52 | t.integer 'intuition', null: false 53 | t.integer 'beauty', null: false 54 | t.datetime 'created_at', precision: 6, null: false 55 | t.datetime 'updated_at', precision: 6, null: false 56 | t.index ['campaign_id'], name: 'index_characters_on_campaign_id' 57 | t.index ['player_id'], name: 'index_characters_on_player_id' 58 | end 59 | 60 | create_table 'specializations', force: :cascade do |t| 61 | t.string 'name', null: false 62 | t.text 'description', null: false 63 | t.json 'properties' 64 | t.datetime 'created_at', precision: 6, null: false 65 | t.datetime 'updated_at', precision: 6, null: false 66 | end 67 | 68 | create_table 'feats', force: :cascade do |t| 69 | t.string 'name', null: false 70 | t.text 'description', null: false 71 | t.integer 'modify_ac', default: 0 72 | t.integer 'modify_hp', default: 0 73 | t.integer 'modify_age', default: 0 74 | t.integer 'modify_stamina', default: 0 75 | t.integer 'modify_strength', default: 0 76 | t.integer 'modify_agility', default: 0 77 | t.integer 'modify_health', default: 0 78 | t.integer 'modify_reasoning', default: 0 79 | t.integer 'modify_memory', default: 0 80 | t.integer 'modify_intuition', default: 0 81 | t.integer 'modify_beauty', default: 0 82 | t.datetime 'created_at', precision: 6, null: false 83 | t.datetime 'updated_at', precision: 6, null: false 84 | end 85 | 86 | create_table 'flaws', force: :cascade do |t| 87 | t.string 'title', null: false 88 | t.text 'description', null: false 89 | t.string 'magnitude' 90 | t.integer 'modify_ac', default: 0 91 | t.integer 'modify_hp', default: 0 92 | t.integer 'modify_age', default: 0 93 | t.integer 'modify_stamina', default: 0 94 | t.integer 'modify_strength', default: 0 95 | t.integer 'modify_agility', default: 0 96 | t.integer 'modify_health', default: 0 97 | t.integer 'modify_reasoning', default: 0 98 | t.integer 'modify_memory', default: 0 99 | t.integer 'modify_intuition', default: 0 100 | t.integer 'modify_beauty', default: 0 101 | t.json 'special_effects' 102 | t.datetime 'created_at', precision: 6, null: false 103 | t.datetime 'updated_at', precision: 6, null: false 104 | end 105 | 106 | create_table 'skills', force: :cascade do |t| 107 | t.string 'name', null: false 108 | t.text 'description', null: false 109 | t.boolean 'core' 110 | t.string 'primary_stat' 111 | t.string 'secondary_stat' 112 | t.string 'tertiary_stat' 113 | t.json 'requirements' 114 | t.datetime 'created_at', precision: 6, null: false 115 | t.datetime 'updated_at', precision: 6, null: false 116 | end 117 | 118 | create_table 'spells', force: :cascade do |t| 119 | t.string 'name', null: false 120 | t.text 'description', null: false 121 | t.integer 'level', null: false, default: 1 122 | t.json 'effects' 123 | t.datetime 'created_at', precision: 6, null: false 124 | t.datetime 'updated_at', precision: 6, null: false 125 | end 126 | 127 | create_table 'virtues', force: :cascade do |t| 128 | t.string 'title', null: false 129 | t.text 'description', null: false 130 | t.string 'magnitude' 131 | t.integer 'modify_ac', default: 0 132 | t.integer 'modify_hp', default: 0 133 | t.integer 'modify_age', default: 0 134 | t.integer 'modify_stamina', default: 0 135 | t.integer 'modify_strength', default: 0 136 | t.integer 'modify_agility', default: 0 137 | t.integer 'modify_health', default: 0 138 | t.integer 'modify_reasoning', default: 0 139 | t.integer 'modify_memory', default: 0 140 | t.integer 'modify_intuition', default: 0 141 | t.integer 'modify_beauty', default: 0 142 | t.json 'special_effects' 143 | t.datetime 'created_at', precision: 6, null: false 144 | t.datetime 'updated_at', precision: 6, null: false 145 | end 146 | 147 | create_table 'characters_feats', force: :cascade do |t| 148 | t.integer 'feat_id', null: false 149 | t.integer 'character_id', null: false 150 | t.text 'backstory' 151 | t.json 'modifications' 152 | t.datetime 'created_at', precision: 6, null: false 153 | t.datetime 'updated_at', precision: 6, null: false 154 | t.index ['character_id'], name: 'index_character_feats_on_character_id' 155 | t.index ['feat_id'], name: 'index_character_feats_on_feat_id' 156 | end 157 | 158 | create_table 'characters_flaws', force: :cascade do |t| 159 | t.integer 'character_id', null: false 160 | t.integer 'flaw_id', null: false 161 | t.integer 'permanence', default: 3 162 | t.text 'backstory' 163 | t.json 'modifications' 164 | t.datetime 'created_at', precision: 6, null: false 165 | t.datetime 'updated_at', precision: 6, null: false 166 | t.index ['character_id'], name: 'index_character_flaws_on_character_id' 167 | t.index ['flaw_id'], name: 'index_character_flaws_on_flaw_id' 168 | end 169 | 170 | create_table 'characters_skills', force: :cascade do |t| 171 | t.integer 'character_id', null: false 172 | t.integer 'skill_id', null: false 173 | t.integer 'trained_level' 174 | t.text 'backstory' 175 | t.json 'modifications' 176 | t.datetime 'created_at', precision: 6, null: false 177 | t.datetime 'updated_at', precision: 6, null: false 178 | t.index ['character_id'], name: 'index_character_skills_on_character_id' 179 | t.index ['skill_id'], name: 'index_character_skills_on_skill_id' 180 | end 181 | 182 | create_table 'characters_virtues', force: :cascade do |t| 183 | t.integer 'character_id', null: false 184 | t.integer 'virtue_id', null: false 185 | t.integer 'permanence', default: 3 186 | t.text 'backstory' 187 | t.json 'modifications' 188 | t.datetime 'created_at', precision: 6, null: false 189 | t.datetime 'updated_at', precision: 6, null: false 190 | t.index ['character_id'], name: 'index_character_virtues_on_character_id' 191 | t.index ['virtue_id'], name: 'index_character_virtues_on_virtue_id' 192 | end 193 | 194 | create_table 'characters_specializations' do |t| 195 | t.integer 'character_id', null: false 196 | t.integer 'specialization_id', null: false 197 | t.integer 'trained_levels', null: false, default: 1 198 | t.text 'backstory' 199 | t.json 'modifications' 200 | t.index ['specialization_id'], name: 'index_char_spec_on_spec_id' 201 | t.index %w(character_id specialization_id), unique: true, name: 'index_char_spec_on_unique_fields' 202 | end 203 | 204 | create_table 'characters_spells' do |t| 205 | t.integer 'character_id', null: false 206 | t.integer 'spell_id', null: false 207 | t.text 'backstory' 208 | t.json 'modifications' 209 | t.index ['spell_id'], name: 'index_char_spells_on_spell_id' 210 | t.index %w(character_id spell_id), unique: true, name: 'index_char_spells_on_unique_fields' 211 | end 212 | 213 | create_table 'items', force: :cascade do |t| 214 | t.string 'name', null: false 215 | t.text 'description' 216 | t.integer 'hardness' 217 | t.integer 'durability' 218 | t.float 'weight' 219 | t.string 'size' 220 | t.integer 'modify_ac', default: 0 221 | t.integer 'modify_hp', default: 0 222 | t.integer 'modify_age', default: 0 223 | t.integer 'modify_stamina', default: 0 224 | t.integer 'modify_strength', default: 0 225 | t.integer 'modify_agility', default: 0 226 | t.integer 'modify_health', default: 0 227 | t.integer 'modify_reasoning', default: 0 228 | t.integer 'modify_memory', default: 0 229 | t.integer 'modify_intuition', default: 0 230 | t.integer 'modify_beauty', default: 0 231 | t.boolean 'equippable', default: false 232 | t.boolean 'consummable', default: false 233 | t.boolean 'weapon', default: false 234 | t.boolean 'armor', default: false 235 | t.json 'special_effects' 236 | t.json 'requirements' 237 | t.datetime 'created_at', precision: 6, null: false 238 | t.datetime 'updated_at', precision: 6, null: false 239 | end 240 | 241 | create_table 'inventories', force: :cascade do |t| 242 | t.integer 'character_id' 243 | t.bigint 'resource_id' 244 | t.string 'resource_type' 245 | t.string 'title' 246 | t.text 'description' 247 | t.float 'weight_limit' 248 | t.integer 'item_limit' 249 | t.json 'special_effects' 250 | t.datetime 'created_at', precision: 6, null: false 251 | t.datetime 'updated_at', precision: 6, null: false 252 | t.index ['character_id'], name: 'index_inventories_on_character_id' 253 | end 254 | 255 | create_table 'inventories_items', force: :cascade do |t| 256 | t.integer 'inventory_id', null: false 257 | t.integer 'item_id', null: false 258 | t.boolean 'equipped', default: false 259 | t.integer 'condition', default: 100 260 | t.json 'modifications' 261 | t.decimal 'price' 262 | t.string 'price_unit', default: 'gp' 263 | t.float 'weight' 264 | t.datetime 'created_at', precision: 6, null: false 265 | t.datetime 'updated_at', precision: 6, null: false 266 | t.index ['inventory_id'], name: 'index_inventory_items_on_inventory_id' 267 | t.index ['item_id'], name: 'index_inventory_items_on_item_id' 268 | end 269 | 270 | add_foreign_key 'campaigns_players', 'campaigns' 271 | add_foreign_key 'campaigns_players', 'players' 272 | add_foreign_key 'characters_feats', 'characters' 273 | add_foreign_key 'characters_feats', 'feats' 274 | add_foreign_key 'characters_flaws', 'characters' 275 | add_foreign_key 'characters_flaws', 'flaws' 276 | add_foreign_key 'characters_skills', 'characters' 277 | add_foreign_key 'characters_skills', 'skills' 278 | add_foreign_key 'characters_virtues', 'characters' 279 | add_foreign_key 'characters_virtues', 'virtues' 280 | add_foreign_key 'characters_spells', 'characters' 281 | add_foreign_key 'characters_spells', 'spells' 282 | add_foreign_key 'characters_specializations', 'characters' 283 | add_foreign_key 'characters_specializations', 'specializations' 284 | add_foreign_key 'characters', 'campaigns' 285 | add_foreign_key 'characters', 'players' 286 | add_foreign_key 'inventories', 'characters' 287 | add_foreign_key 'inventories_items', 'inventories' 288 | add_foreign_key 'inventories_items', 'items' 289 | end 290 | end 291 | --------------------------------------------------------------------------------