diff --git a/.gitignore b/.gitignore index 18b43c9..931707d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ # Ignore master key for decrypting credentials and more. /config/master.key + +.env diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..7008ee7 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,26 @@ +AllCops: + TargetRubyVersion: 2.6.1 + Exclude: + - "config/**/*" + - "db/**/*" + - "bin/**" + - "*Gemfile*" + - "tmp/**/*" + +Metrics/AbcSize: + Enabled: false + +Metrics/LineLength: + Max: 120 + +Metrics/MethodLength: + Max: 15 + +Style/ClassAndModuleChildren: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false diff --git a/Gemfile b/Gemfile index 33017fd..a0ef96f 100644 --- a/Gemfile +++ b/Gemfile @@ -8,9 +8,18 @@ gem 'pg', '>= 0.18', '< 2.0' gem 'puma', '~> 3.11' gem 'bootsnap', '>= 1.1.0', require: false +gem 'activerecord-import' +gem 'dotenv-rails' +gem 'newrelic_rpm' +gem 'oj' +gem 'pghero' +gem 'strong_migrations' + + group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] + gem 'rubocop' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index eb22e16..5189d9b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,6 +33,8 @@ GEM activemodel (= 5.2.3) activesupport (= 5.2.3) arel (>= 9.0) + activerecord-import (1.0.1) + activerecord (>= 3.2) activestorage (5.2.3) actionpack (= 5.2.3) activerecord (= 5.2.3) @@ -43,6 +45,7 @@ GEM minitest (~> 5.1) tzinfo (~> 1.1) arel (9.0.0) + ast (2.4.0) bindex (0.6.0) bootsnap (1.4.2) msgpack (~> 1.0) @@ -50,12 +53,17 @@ GEM byebug (11.0.1) concurrent-ruby (1.1.5) crass (1.0.4) + dotenv (2.7.2) + dotenv-rails (2.7.2) + dotenv (= 2.7.2) + railties (>= 3.2, < 6.1) erubi (1.8.0) ffi (1.10.0) globalid (0.4.2) activesupport (>= 4.2.0) i18n (1.6.0) concurrent-ruby (~> 1.0) + jaro_winkler (1.5.2) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) @@ -73,10 +81,18 @@ GEM mini_portile2 (2.4.0) minitest (5.11.3) msgpack (1.2.9) + newrelic_rpm (6.2.0.354) nio4r (2.3.1) nokogiri (1.10.2) mini_portile2 (~> 2.4.0) + oj (3.7.11) + parallel (1.14.0) + parser (2.6.0.0) + ast (~> 2.4.0) pg (1.1.4) + pghero (2.2.0) + activerecord + psych (3.1.0) puma (3.12.1) rack (2.0.6) rack-test (1.1.0) @@ -105,10 +121,20 @@ GEM method_source rake (>= 0.8.7) thor (>= 0.19.0, < 2.0) + rainbow (3.0.0) rake (12.3.2) rb-fsevent (0.10.3) rb-inotify (0.10.0) ffi (~> 1.0) + rubocop (0.66.0) + jaro_winkler (~> 1.5.1) + parallel (~> 1.10) + parser (>= 2.5, != 2.5.1.1) + psych (>= 3.1.0) + rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 1.6) + ruby-progressbar (1.10.0) ruby_dep (1.5.0) sprockets (3.7.2) concurrent-ruby (~> 1.0) @@ -117,10 +143,13 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) + strong_migrations (0.3.1) + activerecord (>= 3.2.0) thor (0.20.3) thread_safe (0.3.6) tzinfo (1.2.5) thread_safe (~> 0.1) + unicode-display_width (1.5.0) web-console (3.7.0) actionview (>= 5.0) activemodel (>= 5.0) @@ -134,12 +163,19 @@ PLATFORMS ruby DEPENDENCIES + activerecord-import bootsnap (>= 1.1.0) byebug + dotenv-rails listen (>= 3.0.5, < 3.2) + newrelic_rpm + oj pg (>= 0.18, < 2.0) + pghero puma (~> 3.11) rails (~> 5.2.3) + rubocop + strong_migrations tzinfo-data web-console (>= 3.3.0) diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb index acb38be..29bdaf8 100644 --- a/app/controllers/trips_controller.rb +++ b/app/controllers/trips_controller.rb @@ -2,6 +2,8 @@ class TripsController < ApplicationController def index @from = City.find_by_name!(params[:from]) @to = City.find_by_name!(params[:to]) - @trips = Trip.where(from: @from, to: @to).order(:start_time) + @trips = Trip.eager_load(bus: :services) + .where(from: @from, to: @to) + .order(:start_time) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..0bdce32 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,5 @@ module ApplicationHelper + def trips_delimiter + '===================================================='.freeze + end end diff --git a/app/models/bus.rb b/app/models/bus.rb index 1dcc54c..a08b7e4 100644 --- a/app/models/bus.rb +++ b/app/models/bus.rb @@ -1,19 +1,20 @@ class Bus < ApplicationRecord - MODELS = [ - 'Икарус', - 'Мерседес', - 'Сканиа', - 'Буханка', - 'УАЗ', - 'Спринтер', - 'ГАЗ', - 'ПАЗ', - 'Вольво', - 'Газель', + MODELS = %w[ + Икарус + Мерседес + Сканиа + Буханка + УАЗ + Спринтер + ГАЗ + ПАЗ + Вольво + Газель ].freeze has_many :trips - has_and_belongs_to_many :services, join_table: :buses_services + has_many :buses_services + has_many :services, through: :buses_services validates :number, presence: true, uniqueness: true validates :model, inclusion: { in: MODELS } diff --git a/app/models/buses_service.rb b/app/models/buses_service.rb new file mode 100644 index 0000000..6219d44 --- /dev/null +++ b/app/models/buses_service.rb @@ -0,0 +1,4 @@ +class BusesService < ApplicationRecord + belongs_to :bus + belongs_to :service +end diff --git a/app/models/city.rb b/app/models/city.rb index 19ec7f3..1a0f767 100644 --- a/app/models/city.rb +++ b/app/models/city.rb @@ -3,6 +3,6 @@ class City < ApplicationRecord validate :name_has_no_spaces def name_has_no_spaces - errors.add(:name, "has spaces") if name.include?(' ') + errors.add(:name, 'has spaces') if name.include?(' ') end end diff --git a/app/models/service.rb b/app/models/service.rb index 9cbb2a3..3d3b64b 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -9,10 +9,11 @@ class Service < ApplicationRecord 'Телевизор общий', 'Телевизор индивидуальный', 'Стюардесса', - 'Можно не печатать билет', + 'Можно не печатать билет' ].freeze - has_and_belongs_to_many :buses, join_table: :buses_services + has_many :buses_services + has_many :buses, through: :buses_services validates :name, presence: true validates :name, inclusion: { in: SERVICES } diff --git a/app/models/trip.rb b/app/models/trip.rb index 9d63dff..f28e291 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -1,5 +1,5 @@ class Trip < ApplicationRecord - HHMM_REGEXP = /([0-1][0-9]|[2][0-3]):[0-5][0-9]/ + HHMM_REGEXP = /([0-1][0-9]|[2][0-3]):[0-5][0-9]/.freeze belongs_to :from, class_name: 'City' belongs_to :to, class_name: 'City' @@ -25,8 +25,8 @@ def to_h bus: { number: bus.number, model: bus.model, - services: bus.services.map(&:name), - }, + services: bus.services.map(&:name) + } } end end diff --git a/app/services/db_importer.rb b/app/services/db_importer.rb new file mode 100644 index 0000000..139f0cc --- /dev/null +++ b/app/services/db_importer.rb @@ -0,0 +1,80 @@ +require 'oj' + +class DbImporter + def initialize + @cities = {} + @services = {} + @buses = {} + end + + def call(source:) + json = Oj.load_file(source) + + ActiveRecord::Base.transaction do + clear_db! + create_from_json!(json) + end + end + + private + + attr_reader :cities, :services, :buses + + def clear_db! + City.delete_all + Bus.delete_all + Service.delete_all + Trip.delete_all + ActiveRecord::Base.connection.execute('delete from buses_services;') + end + + def create_from_json!(json) + import_cities_and_services(json) + import_buses(json) + import_trips(json) + end + + def import_cities_and_services(json) + json.each do |trip| + cities[trip['from']] ||= City.new(name: trip['from']) + cities[trip['to']] ||= City.new(name: trip['to']) + + trip['bus']['services'].each do |service| + services[service] ||= Service.new(name: service) + end + end + + City.import %i[name], cities.values, syncronize: true, raise_error: true + Service.import %i[name], services.values, syncronize: true, raise_error: true + end + + def import_buses(json) + json.each do |trip| + bus = buses[trip['bus']['number']] || Bus.new(number: trip['bus']['number']) + bus.model = trip['bus']['model'] + bus.services = services.values_at(*trip['bus']['services']) + buses[trip['bus']['number']] = bus + end + + Bus.import buses.values, recursive: true, syncronize: true, raise_error: true + end + + def import_trips(json) + trips = [] + json.each do |trip| + from = cities[trip['from']] + to = cities[trip['to']] + + trips << Trip.new( + from: from, + to: to, + bus: buses[trip['bus']['number']], + start_time: trip['start_time'], + duration_minutes: trip['duration_minutes'], + price_cents: trip['price_cents'] + ) + end + + Trip.import trips, raise_error: true + end +end diff --git a/app/views/trips/_delimiter.html.erb b/app/views/trips/_delimiter.html.erb deleted file mode 100644 index 3f845ad..0000000 --- a/app/views/trips/_delimiter.html.erb +++ /dev/null @@ -1 +0,0 @@ -==================================================== diff --git a/app/views/trips/_service.html.erb b/app/views/trips/_service.html.erb deleted file mode 100644 index 178ea8c..0000000 --- a/app/views/trips/_service.html.erb +++ /dev/null @@ -1 +0,0 @@ -
  • <%= "#{service.name}" %>
  • diff --git a/app/views/trips/_services.html.erb b/app/views/trips/_services.html.erb deleted file mode 100644 index 2de639f..0000000 --- a/app/views/trips/_services.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -
  • Сервисы в автобусе:
  • - diff --git a/app/views/trips/_trip.html.erb b/app/views/trips/_trip.html.erb index fa1de9a..3b86df4 100644 --- a/app/views/trips/_trip.html.erb +++ b/app/views/trips/_trip.html.erb @@ -1,5 +1,16 @@ -
  • <%= "Отправление: #{trip.start_time}" %>
  • -
  • <%= "Прибытие: #{(Time.parse(trip.start_time) + trip.duration_minutes.minutes).strftime('%H:%M')}" %>
  • -
  • <%= "В пути: #{trip.duration_minutes / 60}ч. #{trip.duration_minutes % 60}мин." %>
  • -
  • <%= "Цена: #{trip.price_cents / 100}р. #{trip.price_cents % 100}коп." %>
  • -
  • <%= "Автобус: #{trip.bus.model} №#{trip.bus.number}" %>
  • + +<%= trips_delimiter %> diff --git a/app/views/trips/index.html.erb b/app/views/trips/index.html.erb index a60bce4..f131334 100644 --- a/app/views/trips/index.html.erb +++ b/app/views/trips/index.html.erb @@ -2,15 +2,7 @@ <%= "Автобусы #{@from.name} – #{@to.name}" %>

    - <%= "В расписании #{@trips.count} рейсов" %> + <%= "В расписании #{@trips.length} рейсов" %>

    -<% @trips.each do |trip| %> - - <%= render "delimiter" %> -<% end %> +<%= render partial: "trip", collection: @trips %> diff --git a/config/newrelic.yml b/config/newrelic.yml new file mode 100644 index 0000000..1a36bd9 --- /dev/null +++ b/config/newrelic.yml @@ -0,0 +1,41 @@ +# +# This file configures the New Relic Agent. New Relic monitors Ruby, Java, +# .NET, PHP, Python, Node, and Go applications with deep visibility and low +# overhead. For more information, visit www.newrelic.com. +# +# Generated April 14, 2019 +# +# This configuration file is custom generated for Self-employed_368 +# +# For full documentation of agent configuration options, please refer to +# https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration + +common: &default_settings + # Required license key associated with your New Relic account. + license_key: ENV["NEWRELIC_LICENSE_KEY"] + + # Your application name. Renaming here affects where data displays in New + # Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications + app_name: Task4 + + # To disable the agent regardless of other settings, uncomment the following: + # agent_enabled: false + + # Logging level for log/newrelic_agent.log + log_level: info + + +# Environment-specific settings are in this section. +# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment. +# If your application has other named environments, configure them here. +development: + <<: *default_settings + app_name: Task4 (Development) + +test: + <<: *default_settings + # It doesn't make sense to report to New Relic from automated test runs. + monitor_mode: false + +production: + <<: *default_settings diff --git a/config/routes.rb b/config/routes.rb index a2da6a7..92916b5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,4 +2,6 @@ # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html get "/" => "statistics#index" get "автобусы/:from/:to" => "trips#index" + + mount PgHero::Engine, at: "pghero" end diff --git a/db/migrate/20190414123049_add_indices.rb b/db/migrate/20190414123049_add_indices.rb new file mode 100644 index 0000000..e29c457 --- /dev/null +++ b/db/migrate/20190414123049_add_indices.rb @@ -0,0 +1,16 @@ +class AddIndices < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + add_index :buses_services, [:bus_id, :service_id], unique: true, algorithm: :concurrently + + add_foreign_key :trips, :buses + add_foreign_key :trips, :cities, column: :from_id + add_foreign_key :trips, :cities, column: :to_id + + add_index :trips, :bus_id, algorithm: :concurrently + add_index :trips, :from_id, algorithm: :concurrently + add_index :trips, :to_id, algorithm: :concurrently + add_index :trips, :start_time, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index f6921e4..2c85f2b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_03_30_193044) do +ActiveRecord::Schema.define(version: 2019_04_14_123049) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -23,6 +23,7 @@ create_table "buses_services", force: :cascade do |t| t.integer "bus_id" t.integer "service_id" + t.index ["bus_id", "service_id"], name: "index_buses_services_on_bus_id_and_service_id", unique: true end create_table "cities", force: :cascade do |t| @@ -40,6 +41,13 @@ t.integer "duration_minutes" t.integer "price_cents" t.integer "bus_id" + t.index ["bus_id"], name: "index_trips_on_bus_id" + t.index ["from_id"], name: "index_trips_on_from_id" + t.index ["start_time"], name: "index_trips_on_start_time" + t.index ["to_id"], name: "index_trips_on_to_id" end + add_foreign_key "trips", "buses" + add_foreign_key "trips", "cities", column: "from_id" + add_foreign_key "trips", "cities", column: "to_id" end diff --git a/lib/tasks/utils.rake b/lib/tasks/utils.rake index 540fe87..4f20286 100644 --- a/lib/tasks/utils.rake +++ b/lib/tasks/utils.rake @@ -1,34 +1,14 @@ -# Наивная загрузка данных из json-файла в БД -# rake reload_json[fixtures/small.json] -task :reload_json, [:file_name] => :environment do |_task, args| - json = JSON.parse(File.read(args.file_name)) - - ActiveRecord::Base.transaction do - City.delete_all - Bus.delete_all - Service.delete_all - Trip.delete_all - ActiveRecord::Base.connection.execute('delete from buses_services;') +require 'benchmark' - json.each do |trip| - from = City.find_or_create_by(name: trip['from']) - to = City.find_or_create_by(name: trip['to']) - services = [] - trip['bus']['services'].each do |service| - s = Service.find_or_create_by(name: service) - services << s - end - bus = Bus.find_or_create_by(number: trip['bus']['number']) - bus.update(model: trip['bus']['model'], services: services) +desc 'Import data from json file to DB: rake reload_json[fixtures/small.json]' +task :reload_json, [:file_name] => :environment do |_task, args| + puts "Import data from file #{args.file_name}..." + DbImporter.new.call(source: args.file_name) + puts 'Done!' +end - Trip.create!( - from: from, - to: to, - bus: bus, - start_time: trip['start_time'], - duration_minutes: trip['duration_minutes'], - price_cents: trip['price_cents'], - ) - end - end +desc 'Benchmark :reload_json task: rake reload_json_benchmark[fixtures/small.json]' +task :reload_json_benchmark, [:file_name] => :environment do |_task, args| + bm = Benchmark.measure { Rake::Task['reload_json'].invoke(*args) } + puts bm end diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index d19212a..23701b4 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,4 +1,4 @@ -require "test_helper" +require 'test_helper' class ApplicationSystemTestCase < ActionDispatch::SystemTestCase driven_by :selenium, using: :chrome, screen_size: [1400, 1400] diff --git a/test/fixtures/files/trip_import.json b/test/fixtures/files/trip_import.json new file mode 100644 index 0000000..211d8b0 --- /dev/null +++ b/test/fixtures/files/trip_import.json @@ -0,0 +1 @@ +[{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":168,"from":"Москва","price_cents":474,"start_time":"11:00","to":"Самара"},{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":37,"from":"Самара","price_cents":173,"start_time":"17:30","to":"Москва"},{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":323,"from":"Москва","price_cents":672,"start_time":"12:00","to":"Самара"},{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":315,"from":"Самара","price_cents":969,"start_time":"18:30","to":"Москва"},{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":304,"from":"Москва","price_cents":641,"start_time":"13:00","to":"Самара"},{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":21,"from":"Самара","price_cents":663,"start_time":"19:30","to":"Москва"},{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":598,"from":"Москва","price_cents":629,"start_time":"14:00","to":"Самара"},{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":292,"from":"Самара","price_cents":22,"start_time":"20:30","to":"Москва"},{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":127,"from":"Москва","price_cents":795,"start_time":"15:00","to":"Самара"},{"bus":{"model":"Икарус","number":"123","services":["Туалет","WiFi"]},"duration_minutes":183,"from":"Самара","price_cents":846,"start_time":"21:30","to":"Москва"}] diff --git a/test/integration/trips_index_test.rb b/test/integration/trips_index_test.rb new file mode 100644 index 0000000..b552e4a --- /dev/null +++ b/test/integration/trips_index_test.rb @@ -0,0 +1,28 @@ +require 'test_helper' + +class TripsIndexTest < ActionDispatch::IntegrationTest + def setup + DbImporter.new.call(source: 'test/fixtures/files/trip_import.json') + end + + test 'responds with success status' do + make_request + + assert_response :success + end + + test 'displays all relevant records' do + make_request + + assert response.body.include?('В расписании 5 рейсов') + + Trip.where(from: 'Самара', to: 'Москва').find_each do |trip| + assert response.body.include?("Отправление: #{trip.start_time}") + assert response.body.include?("Автобус: #{trip.bus.model} №#{trip.bus.number}") + end + end + + def make_request + get URI.encode('/автобусы/Самара/Москва') + end +end diff --git a/test/services/db_importer_test.rb b/test/services/db_importer_test.rb new file mode 100644 index 0000000..afea558 --- /dev/null +++ b/test/services/db_importer_test.rb @@ -0,0 +1,31 @@ +require 'test_helper' + +class DbImporterTest < ActiveSupport::TestCase + test 'imports all presented records' do + assert_difference 'Trip.count', 10 do + import! + end + end + + test 'correctly populates records attributes' do + import! + + actual_trip = Trip.first.to_h + expected_trip = { + from: 'Москва', + to: 'Самара', + start_time: '11:00', + duration_minutes: 168, + price_cents: 474, + bus: { number: '123', model: 'Икарус', services: %w[Туалет WiFi] } + } + + assert_equal expected_trip, actual_trip + end + + private + + def import! + DbImporter.new.call(source: 'test/fixtures/files/trip_import.json') + end +end