Introduce uniqueness index and validation for points

This commit is contained in:
Eugene Burmakin 2025-01-20 17:59:13 +01:00
parent 6c0a954e8e
commit 6644fc9a13
28 changed files with 204 additions and 49 deletions

View file

@ -21,6 +21,9 @@ class Api::V1::PointsController < ApiController
render json: serialized_points
end
def create
end
def update
point = current_api_user.tracked_points.find(params[:id])

View file

@ -6,7 +6,6 @@ class MapController < ApplicationController
def index
@points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
@countries_and_cities = CountriesAndCities.new(@points).call
@coordinates =
@points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country)
.map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }

View file

@ -4,12 +4,13 @@ class Points::CreateJob < ApplicationJob
queue_as :default
def perform(params, user_id)
data = Overland::Params.new(params).call
data = Points::Params.new(params, user_id).call
data.each do |location|
next if point_exists?(location, user_id)
Point.create!(location.merge(user_id:))
data.each_slice(1000) do |location_batch|
Point.upsert_all(
location_batch,
unique_by: %i[latitude longitude timestamp user_id]
)
end
end

View file

@ -8,7 +8,11 @@ class Point < ApplicationRecord
belongs_to :user
validates :latitude, :longitude, :timestamp, presence: true
validates :timestamp, uniqueness: {
scope: %i[latitude longitude user_id],
message: 'already has a point at this location and time for this user',
index: true
}
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true
enum :trigger, {
unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3,

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class RemoveDuplicatePoints < ActiveRecord::Migration[8.0]
def up
# Find duplicate groups using a subquery
duplicate_groups =
Point.select('latitude, longitude, timestamp, user_id, COUNT(*) as count')
.group('latitude, longitude, timestamp, user_id')
.having('COUNT(*) > 1')
puts "Duplicate groups found: #{duplicate_groups.length}"
duplicate_groups.each do |group|
points = Point.where(
latitude: group.latitude,
longitude: group.longitude,
timestamp: group.timestamp,
user_id: group.user_id
).order(id: :asc)
# Keep the latest record and destroy all others
latest = points.last
points.where.not(id: latest.id).destroy_all
end
end
def down
# This migration cannot be reversed
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20250104204852)
DataMigrate::Data.define(version: 20250120154554)

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddUniqueIndexToPoints < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def up
add_index :points, %i[latitude longitude timestamp user_id],
unique: true,
name: 'unique_points_index',
algorithm: :concurrently
end
def down
remove_index :points, name: 'unique_points_index'
end
end

3
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_20_152540) do
ActiveRecord::Schema[8.0].define(version: 2025_01_20_154555) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -168,6 +168,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_20_152540) do
t.index ["external_track_id"], name: "index_points_on_external_track_id"
t.index ["geodata"], name: "index_points_on_geodata", using: :gin
t.index ["import_id"], name: "index_points_on_import_id"
t.index ["latitude", "longitude", "timestamp", "user_id"], name: "unique_points_index", unique: true
t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude"
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
t.index ["timestamp"], name: "index_points_on_timestamp"

View file

@ -25,6 +25,10 @@ FactoryBot.define do
import_id { '' }
city { nil }
country { nil }
reverse_geocoded_at { nil }
course { nil }
course_accuracy { nil }
external_track_id { nil }
user
trait :with_known_location do

View file

@ -10,11 +10,15 @@ FactoryBot.define do
trait :with_points do
after(:build) do |trip|
create_list(
:point, 25,
user: trip.user,
timestamp: trip.started_at + (1..1000).to_a.sample.minutes
)
(1..25).map do |i|
create(
:point,
:with_geodata,
:reverse_geocoded,
timestamp: trip.started_at + i.minutes,
user: trip.user
)
end
end
end
end

File diff suppressed because one or more lines are too long

View file

@ -9,8 +9,17 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
let(:timestamp) { DateTime.new(2024, 1, 1).to_i }
let!(:points1) { create_list(:point, 10, user_id: user1.id, timestamp:) }
let!(:points2) { create_list(:point, 10, user_id: user2.id, timestamp:) }
let!(:points1) do
(1..10).map do |i|
create(:point, user_id: user1.id, timestamp: timestamp + i.minutes)
end
end
let!(:points2) do
(1..10).map do |i|
create(:point, user_id: user2.id, timestamp: timestamp + i.minutes)
end
end
it 'enqueues Stats::CalculatingJob for each user' do
expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1)

View file

@ -26,7 +26,11 @@ RSpec.describe Import, type: :model do
describe '#years_and_months_tracked' do
let(:import) { create(:import) }
let(:timestamp) { Time.zone.local(2024, 11, 1) }
let!(:points) { create_list(:point, 3, import:, timestamp:) }
let!(:points) do
(1..3).map do |i|
create(:point, import:, timestamp: timestamp + i.minutes)
end
end
it 'returns years and months tracked' do
expect(import.years_and_months_tracked).to eq([[2024, 11]])

View file

@ -89,8 +89,14 @@ RSpec.describe Stat, type: :model do
subject { stat.points.to_a }
let(:stat) { create(:stat, year:, month: 1, user:) }
let(:timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) }
let!(:points) { create_list(:point, 3, user:, timestamp:) }
let(:base_timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) }
let!(:points) do
[
create(:point, user:, timestamp: base_timestamp),
create(:point, user:, timestamp: base_timestamp + 1.hour),
create(:point, user:, timestamp: base_timestamp + 2.hours)
]
end
it 'returns points' do
expect(subject).to eq(points)

View file

@ -115,7 +115,11 @@ RSpec.describe User, type: :model do
end
describe '#years_tracked' do
let!(:points) { create_list(:point, 3, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0)) }
let!(:points) do
(1..3).map do |i|
create(:point, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0) + i.minutes)
end
end
it 'returns years tracked' do
expect(user.years_tracked).to eq([{ year: 2024, months: ['Jan'] }])

View file

@ -4,7 +4,11 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Points', type: :request do
let!(:user) { create(:user) }
let!(:points) { create_list(:point, 150, user:) }
let!(:points) do
(1..15).map do |i|
create(:point, user:, timestamp: 1.day.ago + i.minutes)
end
end
describe 'GET /index' do
context 'when regular version of points is requested' do
@ -21,7 +25,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.size).to eq(100)
expect(json_response.size).to eq(15)
end
it 'returns a list of points with pagination' do
@ -31,7 +35,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.size).to eq(10)
expect(json_response.size).to eq(5)
end
it 'returns a list of points with pagination headers' do
@ -40,7 +44,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
expect(response).to have_http_status(:ok)
expect(response.headers['X-Current-Page']).to eq('2')
expect(response.headers['X-Total-Pages']).to eq('15')
expect(response.headers['X-Total-Pages']).to eq('2')
end
end
@ -58,7 +62,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.size).to eq(100)
expect(json_response.size).to eq(15)
end
it 'returns a list of points with pagination' do
@ -68,7 +72,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.size).to eq(10)
expect(json_response.size).to eq(5)
end
it 'returns a list of points with pagination headers' do
@ -77,7 +81,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
expect(response).to have_http_status(:ok)
expect(response.headers['X-Current-Page']).to eq('2')
expect(response.headers['X-Total-Pages']).to eq('15')
expect(response.headers['X-Total-Pages']).to eq('2')
end
it 'returns a list of points with slim attributes' do

View file

@ -10,14 +10,20 @@ RSpec.describe 'Api::V1::Stats', type: :request do
let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) }
let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) }
let!(:points_in_2020) do
create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:)
(1..85).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:)
end
end
let!(:points_in_2021) do
(1..95).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:)
end
end
let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) }
let(:expected_json) do
{
totalDistanceKm: stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
totalPointsTracked: points_in_2020.count + points_in_2021.count,
totalReverseGeocodedPoints: points_in_2020.count,
totalReverseGeocodedPoints: points_in_2020.count + points_in_2021.count,
totalCountriesVisited: 1,
totalCitiesVisited: 1,
yearlyStats: [

View file

@ -37,7 +37,11 @@ RSpec.describe '/exports', type: :request do
before { sign_in user }
context 'with valid parameters' do
let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) }
let(:points) do
(1..10).map do |i|
create(:point, user:, timestamp: 1.day.ago + i.minutes)
end
end
it 'creates a new Export' do
expect { post exports_url, params: }.to change(Export, :count).by(1)

View file

@ -11,7 +11,11 @@ RSpec.describe 'Map', type: :request do
describe 'GET /index' do
context 'when user signed in' do
let(:user) { create(:user) }
let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) }
let(:points) do
(1..10).map do |i|
create(:point, user:, timestamp: 1.day.ago + i.minutes)
end
end
before { sign_in user }

View file

@ -7,7 +7,12 @@ RSpec.describe ExportSerializer do
subject(:serializer) { described_class.new(points, user_email).call }
let(:user_email) { 'ab@cd.com' }
let(:points) { create_list(:point, 2) }
let(:points) do
(1..2).map do |i|
create(:point, timestamp: 1.day.ago + i.minutes)
end
end
let(:expected_json) do
{
user_email => {

View file

@ -6,7 +6,12 @@ RSpec.describe Points::GeojsonSerializer do
describe '#call' do
subject(:serializer) { described_class.new(points).call }
let(:points) { create_list(:point, 3) }
let(:points) do
(1..3).map do |i|
create(:point, timestamp: 1.day.ago + i.minutes)
end
end
let(:expected_json) do
{
type: 'FeatureCollection',

View file

@ -6,7 +6,11 @@ RSpec.describe Points::GpxSerializer do
describe '#call' do
subject(:serializer) { described_class.new(points, 'some_name').call }
let(:points) { create_list(:point, 3) }
let(:points) do
(1..3).map do |i|
create(:point, timestamp: 1.day.ago + i.minutes)
end
end
it 'returns GPX file' do
expect(serializer).to be_a(GPX::GPXFile)

View file

@ -29,16 +29,20 @@ RSpec.describe StatsSerializer do
let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) }
let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) }
let!(:points_in_2020) do
create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:)
(1..85).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:)
end
end
let!(:points_in_2021) do
create_list(:point, 95, timestamp: Time.zone.local(2021), user:)
(1..95).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:)
end
end
let(:expected_json) do
{
"totalDistanceKm": stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
"totalPointsTracked": points_in_2020.count + points_in_2021.count,
"totalReverseGeocodedPoints": points_in_2020.count,
"totalReverseGeocodedPoints": points_in_2020.count + points_in_2021.count,
"totalCountriesVisited": 1,
"totalCitiesVisited": 1,
"yearlyStats": [

View file

@ -15,7 +15,12 @@ RSpec.describe Exports::Create do
let(:export_content) { Points::GeojsonSerializer.new(points).call }
let(:reverse_geocoded_at) { Time.zone.local(2021, 1, 1) }
let!(:points) do
create_list(:point, 10, :with_known_location, user:, timestamp: start_at.to_datetime.to_i, reverse_geocoded_at:)
10.times.map do |i|
create(:point, :with_known_location,
user: user,
timestamp: start_at.to_datetime.to_i + i,
reverse_geocoded_at: reverse_geocoded_at)
end
end
before do

View file

@ -7,7 +7,7 @@ RSpec.describe GoogleMaps::RecordsParser do
subject(:parser) { described_class.new(import).call(json) }
let(:import) { create(:import) }
let(:time) { Time.zone.now }
let(:time) { DateTime.new(2025, 1, 1, 12, 0, 0) }
let(:json) do
{
'latitudeE7' => 123_456_789,
@ -31,7 +31,7 @@ RSpec.describe GoogleMaps::RecordsParser do
before do
create(
:point, user: import.user, import:, latitude: 12.3456789, longitude: 12.3456789,
timestamp: Time.zone.now.to_i
timestamp: time.to_i
)
end
@ -78,4 +78,4 @@ RSpec.describe GoogleMaps::RecordsParser do
end
end
end
end
end

View file

@ -8,7 +8,12 @@ RSpec.describe Jobs::Create do
context 'when job_name is start_reverse_geocoding' do
let(:user) { create(:user) }
let(:points) { create_list(:point, 4, user:) }
let(:points) do
(1..4).map do |i|
create(:point, user:, timestamp: 1.day.ago + i.minutes)
end
end
let(:job_name) { 'start_reverse_geocoding' }
it 'enqueues reverse geocoding for all user points' do
@ -24,8 +29,17 @@ RSpec.describe Jobs::Create do
context 'when job_name is continue_reverse_geocoding' do
let(:user) { create(:user) }
let(:points_without_address) { create_list(:point, 4, user:, country: nil, city: nil) }
let(:points_with_address) { create_list(:point, 5, user:, country: 'Country', city: 'City') }
let(:points_without_address) do
(1..4).map do |i|
create(:point, user:, country: nil, city: nil, timestamp: 1.day.ago + i.minutes)
end
end
let(:points_with_address) do
(1..5).map do |i|
create(:point, user:, country: 'Country', city: 'City', timestamp: 1.day.ago + i.minutes)
end
end
let(:job_name) { 'continue_reverse_geocoding' }

View file

@ -58,7 +58,11 @@ describe 'Points API', type: :request do
let(:api_key) { user.api_key }
let(:start_at) { Time.zone.now - 1.day }
let(:end_at) { Time.zone.now }
let(:points) { create_list(:point, 10, user:, timestamp: 2.hours.ago) }
let(:points) do
(1..10).map do |i|
create(:point, user:, timestamp: 2.hours.ago + i.minutes)
end
end
run_test!
end

View file

@ -57,8 +57,18 @@ describe 'Stats API', type: :request do
let!(:user) { create(:user) }
let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) }
let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) }
let!(:points_in_2020) { create_list(:point, 85, :with_geodata, timestamp: Time.zone.local(2020), user:) }
let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) }
let!(:points_in_2020) do
(1..85).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours,
user:)
end
end
let!(:points_in_2021) do
(1..95).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours,
user:)
end
end
let(:api_key) { user.api_key }
run_test!