Browse Source

Introduce pagination to indexes.

The Query interface has been refactored for this: moved all query into
the projectors and out of models. Models are now very simply presenters
and nothing more.
tags/0.3.0^2
Bèr Kessels 1 year ago
parent
commit
2220f9b35e

+ 0
- 17
app/models/base.rb View File

@@ -5,23 +5,6 @@ module Hours
##
# Base model that can read from a query database
class Base
def self.dataset
Hours.projections_database[self::TABLE]
end

def self.find(primary_key)
result = dataset[id: primary_key]
new(result) if result
end

def self.all
result = []
dataset.all do |row|
result << new(row)
end
result
end

def initialize(attributes = {})
attributes.each do |key, value|
send("#{key}=", value)

+ 9
- 1
app/models/base_collection.rb View File

@@ -4,13 +4,21 @@ module Hours
module Models
##
# Base model that holds a list of other models
class BaseCollection
class BaseCollection < Delegator
include Enumerable

def __getobj__
@dataset
end

def initialize(dataset)
@dataset = dataset
end

def to_a
@dataset.map { |row| self.class::ITEM_CLASS.new(row) }
end

def each
@dataset.each do |row|
if row

+ 1
- 3
app/models/place.rb View File

@@ -14,10 +14,8 @@ module Hours
class Place < Base
ITERATOR = OpeningHoursConverter::Iterator.new
PARSER = OpeningHoursConverter::OpeningHoursParser.new
TABLE = :query_places
PK = :id

attr_accessor PK, :location, :place_id, :name,
attr_accessor :id, :location, :place_id, :name,
:region_slug
attr_writer :address, :opening_hours


+ 21
- 0
app/models/place_index.rb View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true

require_relative 'base_collection.rb'

module Hours
module Models
# Utility class to hold a place that is empty
class NullPlace
def region
''
end
end

##
# A collection of places
class PlaceIndex < BaseCollection
ITEM_CLASS = ::Hours::Models::Place
NULLITEM_CLASS = ::Hours::Models::NullPlace
end
end
end

+ 2
- 11
app/models/region.rb View File

@@ -1,23 +1,14 @@
# frozen_string_literal: true

require_relative 'base.rb'
require_relative 'place_index.rb'

module Hours
module Models
# Utility class to hold a place that is empty
class NullPlace
def region
''
end
end

##
# Region is a collection of Places belonging to one region.
# It is, therefore, a decorator for a list of places.
class Region < BaseCollection
ITEM_CLASS = ::Hours::Models::Place
NULLITEM_CLASS = ::Hours::Models::NullPlace

class Region < PlaceIndex
def name
first.region
end

+ 32
- 24
app/projections/query.rb View File

@@ -2,37 +2,45 @@

module Hours
module Projections
module Place
# Query handler that queries the projection table for a single node
class Query
def self.handle(id)
Hours::Models::Place.find(id)
end

def self.uuid_for(place_id)
Hours::Models::Place.dataset.where(place_id: place_id).get(:id)
end
class BaseQuery
DEFAULT_DATASET = Hours.projections_database[:query_places]
attr_reader :dataset

def initialize(dataset)
@dataset = dataset
end

def self.build
new(DEFAULT_DATASET)
end
end

# Query handler that queries the projection table for a single node
class PlaceQuery < BaseQuery
def handle(id)
result = dataset[id: id]
Hours::Models::Place.new(result) if result
end

def uuid_for(place_id)
dataset.where(place_id: place_id).get(:id)
end
end

module Places
# Query handler that queries the projection table for all nodes
class Query
def self.handle
Hours::Models::Place.all
end
# Query handler that queries the projection table for all nodes
class PlacesQuery < BaseQuery
def handle(page = 1)
Hours::Models::PlaceIndex.new(dataset.paginate(page, 30))
end
end

module Region
# Query handler that builds a list of places in a Region
class Query
def self.handle(region_slug)
raise ArgumentError if region_slug.nil? || region_slug.empty?
# Query handler that builds a list of places in a Region
class RegionQuery < BaseQuery
def handle(region_slug, page = 1)
raise ArgumentError if region_slug.nil? || region_slug.empty?

dataset = Hours::Models::Place.dataset.where(region_slug: region_slug)
Hours::Models::Region.new(dataset)
end
result = dataset.where(region_slug: region_slug).paginate(page, 30)
Hours::Models::Region.new(result)
end
end
end

+ 1
- 0
config/event_sourcery.rb View File

@@ -21,4 +21,5 @@ EventSourcery::Postgres.configure do |config|
config.projections_database = database

config.projections_database.extension :postgis_georuby
config.projections_database.extension :pagination
end

+ 10
- 6
lib/app.rb View File

@@ -2,6 +2,7 @@

# This contains the Sinatra App
require_relative './hours.rb'
require_relative './pagination_helpers.rb'
require_relative Hours.base_path.join('config/event_sourcery.rb')

require 'erubis'
@@ -18,6 +19,7 @@ require 'jsonapi/serializable'
module Hours
# Main Web Application using Sinatra.
class Web < Sinatra::Base
include PaginationHelpers
# rubocop:disable Metrics/LineLength
# Somehow, Sinatra Mustermann cannot handle multiline regexes /foo
# bar/x
@@ -88,16 +90,17 @@ module Hours
URI.join(request.base_url, "/places/#{id}").to_s
end

def render_json(data)
def render_json(data, page_links = nil)
render = JSONAPI::Serializable::Renderer.new
render.render(
data,
class: { "Hours::Models::Place": Hours::Serializers::Place }
class: { "Hours::Models::Place": Hours::Serializers::Place },
links: page_links
).to_json
end

def handle_get_place(&block)
@place = Hours::Projections::Place::Query.handle(params[:uuid])
@place = Hours::Projections::PlaceQuery.build.handle(params[:uuid])
if @place
yield block
else
@@ -122,7 +125,8 @@ module Hours
end

get '/places' do
body render_json(Hours::Projections::Places::Query.handle)
places_index = Hours::Projections::PlacesQuery.build.handle(page)
body render_json(places_index.to_a, page_links(places_index))
end

post '/places' do
@@ -133,7 +137,7 @@ module Hours
end

get '/in/:slug', provides: 'html' do
@region = Hours::Projections::Region::Query.handle(params[:slug])
@region = Hours::Projections::RegionQuery.build.handle(params[:slug])
erb :region
end

@@ -150,7 +154,7 @@ module Hours
end

get '/places/:place_id', provides: 'json' do
uuid = Hours::Projections::Place::Query.uuid_for(params[:place_id])
uuid = Hours::Projections::PlaceQuery.build.uuid_for(params[:place_id])
redirect to("/places/#{uuid}"), 303
end
end

+ 29
- 0
lib/pagination_helpers.rb View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true

##
# Helper methods to generate pagination links
module PaginationHelpers
private

def page_links(paginator)
path = request.path

{
self: request.fullpath,
first: paged_path(path, 1),
last: paged_path(path, paginator.page_count),
prev: paged_path(path, paginator.prev_page),
next: paged_path(path, paginator.next_page)
}
end

def page
params.fetch(:page, {}).fetch('number', 1).to_i
end

def paged_path(path, page)
return if page.nil?

"#{path}?page[number]=#{page}"
end
end

+ 8
- 1
test/fixtures/output/places.json View File

@@ -1,8 +1,15 @@
{
"links": {
"self" : "/places",
"first": "/places?page[number]=1",
"last": "/places?page[number]=1",
"prev": null,
"next": null
},
"data": [
{
"links" : {
"self" : "/places/"
"self" : "/places/:UUID"
},
"meta" : {
"copyright" : "OpenStreetMap-contributors"

+ 3
- 3
test/integration/api/add_places_test.rb View File

@@ -27,7 +27,7 @@ describe 'add place' do

assert_equal(
id_from_header,
Hours::Projections::Places::Query.handle.first.id
Hours::Projections::PlacesQuery.build.handle.first.id
)
end

@@ -41,7 +41,7 @@ describe 'add place' do
end

it 'does not add a new place in the query repo' do
assert_equal(1, Hours::Projections::Places::Query.handle.length)
assert_equal(1, Hours::Projections::PlacesQuery.build.handle.count)

post_json '/places', payload_nearby
assert_status(422)
@@ -53,7 +53,7 @@ describe 'add place' do
assert_equal(@aggregate_ids.first, @aggregate_ids.last)

projector_process_event(@aggregate_ids.last)
assert_equal(1, Hours::Projections::Places::Query.handle.length)
assert_equal(1, Hours::Projections::PlacesQuery.build.handle.count)
end
end


+ 41
- 0
test/integration/api/view_places_test.rb View File

@@ -33,6 +33,47 @@ describe 'api view places' do
end
end

describe 'with 31 places' do
let(:input) do
Array.new(31) do |i|
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [5, 51] },
properties: {
name: "place ##{i} #{Digest::SHA1.hexdigest(i.to_s)}",
shop: 'clothes',
'addr:city': 'Nijmegen'
}
}
end
end

it 'paginates the places' do
get '/places'

parsed = JSON.parse(last_response.body, symbolize_names: true)
assert_equal parsed[:data].length, 30

expected = json_fixtures('output/places.json')
expected[:data] = [].ignore_extra_values
expected[:links][:last] = '/places?page[number]=2'
expected[:links][:next] = '/places?page[number]=2'

assert_json_match(expected, parsed)

# Move to the second page
get parsed[:links][:next]
parsed = JSON.parse(last_response.body, symbolize_names: true)
expected[:links][:self] = '/places?page[number]=2'
expected[:links][:last] = '/places?page[number]=2'
expected[:links][:prev] = '/places?page[number]=1'
expected[:links][:next] = nil
assert_json_match(expected, parsed)

assert_equal parsed[:data].length, 1
end
end

it 'on a thursday, after 21:00 it is closed' do
after_hours = die_wende + (60 * 60 * 4)
Timecop.travel(after_hours) do

+ 11
- 1
test/models/base_collection_test.rb View File

@@ -6,7 +6,9 @@ require_relative Hours.base_path.join 'app/models/base_collection.rb'
describe Hours::Models::BaseCollection do
module Hours
module Models
class Mock; end
class Mock < Hours::Models::Base
attr_accessor :id
end
class MockCollection < BaseCollection
ITEM_CLASS = Mock
NULLITEM_CLASS = Class
@@ -29,4 +31,12 @@ describe Hours::Models::BaseCollection do
end
assert_equal row[:id], subject.first.id
end

it 'delegates all methods to the dataset' do
dataset = OpenStruct.new(weird_method: 'weird answer')
subject = Hours::Models::MockCollection.new(dataset)
# Delegator interfereces with Minitest::Mock so we test with our own
# mock
assert_equal 'weird answer', subject.weird_method
end
end

+ 0
- 37
test/models/base_test.rb View File

@@ -4,41 +4,4 @@ require 'test_helper'
require_relative Hours.base_path.join 'app/models/base.rb'

describe Hours::Models::Base do
module Hours
module Models
class Mock < Base
TABLE = :query_places
PK = :id

attr_accessor PK, :address, :location, :place_id, :opening_hours,
:name, :region_slug
end
end
end

let(:pk) { SecureRandom.uuid }
let(:attributes) { { id: pk, place_id: 'PLACE;ID', region_slug: 'rs' } }

before do
writer = Hours.projections_database
assert writer[:query_places].insert(attributes)
end

describe 'find()' do
it 'reads from :query_places' do
assert_kind_of Hours::Models::Mock, Hours::Models::Mock.find(pk)
end

it 'handles not found with nil' do
assert_nil Hours::Models::Mock.find(SecureRandom.uuid)
end
end

describe 'all()' do
it 'reads all from :query_places' do
result = Hours::Models::Mock.all
assert_equal 1, result.length
assert_kind_of Hours::Models::Mock, result.first
end
end
end

+ 16
- 0
test/models/place_index_test.rb View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true

require 'test_helper'

describe Hours::Models::PlaceIndex do
let(:row) { { id: 'PK' } }
let(:dataset) { [row] }

subject do
Hours::Models::PlaceIndex.new(dataset)
end

it 'is a collection of places' do
assert_kind_of Hours::Models::Place, subject.first
end
end

+ 45
- 8
test/projections/query_test.rb View File

@@ -3,28 +3,43 @@
require 'test_helper'
require_relative Hours.base_path.join('test/support/workflows/add_place.rb')

describe Hours::Projections::Places::Query do
describe Hours::Projections::PlacesQuery do
# TODO: we might not need to run through the entire event process here.
# instead, we could insert data directly into the query database.
include Workflows::AddPlace
let(:input) { [json_fixtures('input/hm_burchtstraat.json')] }
let(:hm) { json_fixtures('input/hm_burchtstraat.json') }
let(:input) { [hm] }

subject do
Hours::Projections::Places::Query
Hours::Projections::PlacesQuery.build
end

it '#handle fetches a list of Places through Sequel model' do
assert_kind_of Array, subject.handle
assert_kind_of Hours::Models::PlaceIndex, subject.handle
assert_kind_of Hours::Models::Place, subject.handle.first
end

it 'pages all from :query_places in pages of 30' do
30.times.each do
assert subject.dataset.insert(
id: SecureRandom.uuid,
place_id: SecureRandom.hex,
region_slug: ''
)
end

assert_equal 31, Hours::Projections::PlacesQuery.build.dataset.count

assert_equal 30, subject.handle(1).count
end
end

describe Hours::Projections::Place::Query do
describe Hours::Projections::PlaceQuery do
include Workflows::AddPlace
let(:input) { [json_fixtures('input/hm_burchtstraat.json')] }

subject do
Hours::Projections::Place::Query
Hours::Projections::PlaceQuery.build
end

it '#handle fetches a single of Places through Sequel model' do
@@ -38,18 +53,40 @@ describe Hours::Projections::Place::Query do
end
end

describe Hours::Projections::Region::Query do
describe Hours::Projections::RegionQuery do
include Workflows::AddPlace
let(:input) { [json_fixtures('input/hm_burchtstraat.json')] }

subject do
Hours::Projections::Region::Query
Hours::Projections::RegionQuery.build
end

it '#handle fetches a single of Places through Sequel model' do
assert_kind_of Hours::Models::Region, subject.handle('nijmegen')
end

it 'pages from :query_places in pages of 30' do
30.times.each do
assert subject.dataset.insert(
id: SecureRandom.uuid,
place_id: SecureRandom.hex,
region_slug: 'nijmegen'
)
end
assert subject.dataset.insert(
id: SecureRandom.uuid,
place_id: SecureRandom.hex,
region_slug: 'arnhem'
)

assert_equal 32, subject.dataset.count
assert_equal 31, subject.dataset.where(region_slug: 'nijmegen').count

result = subject.handle('nijmegen', 1)
assert_equal 30, result.count
assert_kind_of Hours::Models::Place, result.first
end

describe 'avoid looking up places without regions' do
it '#handle does not allow nil for slug' do
assert_raises ArgumentError do

Loading…
Cancel
Save