Skip to content

Commit

Permalink
Rewrite loading …and also the gem it seems (#54)
Browse files Browse the repository at this point in the history
* First stab at rewriting our loading structure

* Expose loader on Seeds and each loadable

* Keep track of current entry to track readers properly

* Delete old implementation; I'm already feeling pretty confident about where this new version is headed

* Use a PStore to help store our details between runs

* Fix Ruby 3.0 support

* Move this down for hopefully cleaner diff

* Blend our concepts between Entry and Store a bit and see what that's like

* I do love me some instance variables but also… um

* Tweak message a bit

* Inject current environment in path prefix

* Extract store_path to a config; still reliant on Rails.env, but that's ok for now

* Slim Loader some more, not sure if we need it, but I like it for now

* Move Entry out of Loader namespace

* Graduate Entry into its own file

* We're not relying on YAML anymore

* Extract more concepts into files and refactor a bit

* Inject env via a Railtie

* Prepare for supplanting loading from test/seeds; skip loader reentrance checks now that they fail

* Mirror type, key argument structure and refit the code a bit …I'm not quite sure why yet

* Make an attempt at defining what our default preseeding is

* Replace preregister and connection need with a method_missing lazy hook

* Make db/seeds work as a top-level configuration point for Oaken; I'm pretty sleepy so this is probably somewhat experimental

* Include an env override to reset cache in case it's corrupt

* Don't make seeds all-in by default to allow one-off cases.

By having our loading be all-in we need to exclude a lot of found file paths,
and gives users less control to tweak things.

Instead we can have lookup paths to help define our loading behavior,
and then for each given path we can look in those.

This lets us support one-off cases for e.g. a pagination test where you want to load
many objects but you don't need those for other tests.

I'm setting up a pattern of those being in `db/seeds/cases` or `db/seeds/<env>/cases`.
  • Loading branch information
kaspth authored Oct 18, 2023
1 parent 0245c92 commit 505dd68
Show file tree
Hide file tree
Showing 14 changed files with 179 additions and 133 deletions.
156 changes: 28 additions & 128 deletions lib/oaken.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

require "oaken/version"
require "pathname"
require "yaml"
require "digest/md5"

module Oaken
class Error < StandardError; end

autoload :Seeds, "oaken/seeds"
autoload :Entry, "oaken/entry"

module Stored
autoload :ActiveRecord, "oaken/stored/active_record"
end

class Inflector
def tableize(string)
string.gsub(/(?<=[a-z])(?=[A-Z])/, "_").gsub("::", "_").tap(&:downcase!) << "s"
Expand All @@ -21,143 +26,38 @@ def classify(string)
singleton_class.attr_accessor :inflector
@inflector = Inflector.new

module Stored; end
class Stored::ActiveRecord
def initialize(key, type)
@key, @type = key, type
end

def find(id)
@type.find id
end

def create(reader = nil, **attributes)
@type.create!(**attributes).tap do |record|
add_reader reader, record.id if reader
end
end

def insert(reader = nil, **attributes)
@type.new(attributes).validate!
@type.insert(attributes).tap do
add_reader reader, @type.where(attributes).pick(:id) if reader
end
end

private
def add_reader(name, id)
location = caller_locations(2, 10).find { _1.path.match?(/(db|test)\/seeds/) }
Seeds.result.run(location.path).add_reader @key, name, id, location
instance_eval "def #{name}; find #{id}; end", location.path, location.lineno
end
end

module Seeds
extend self

def self.preregister(names)
names.each do |name|
type = Oaken.inflector.classify(name).safe_constantize and register type, name
end
end

def self.register(type, key = Oaken.inflector.tableize(type.name))
stored = Stored::ActiveRecord.new(key, type)
define_method(key) { stored }
end

singleton_class.attr_reader :result

def self.load_from(directory)
@result = Result.new(directory)
@result.process do |run, path|
if run.processed? path
run.replay self
else
path.process
end
end
end
end

class Result
def initialize(directory)
@directory = directory
@path = Pathname.new("./tmp/oaken-result-#{Rails.env}.yml")
@runs = @path.exist? ? YAML.load(@path.read) : {}
@runs.transform_values! { Run.new(**_1) }
@runs.default_proc = ->(h, k) { h[k] = Run.new(path: k) }
end

def process
Pathname.glob("#{@directory}{,/**/*}.rb").sort.each do |path|
path = Oaken::Path.new(Oaken::Seeds, path)
yield run(path), path
self << path
end

write
end
singleton_class.attr_reader :lookup_paths
@lookup_paths = ["db/seeds"]

def run(path)
@runs[path.to_s]
end
singleton_class.attr_accessor :store_path
@store_path = Pathname.new "tmp/oaken/store"

def <<(path)
run(path).checksum = path.checksum
end
class Loader
attr_reader :entry

def write
@path.dirname.mkpath
@path.write YAML.dump(@runs.transform_values(&:to_h))
def initialize(path)
@entries, @entry = Entry.within(path), nil
end

class Run
attr_accessor :checksum

def initialize(path:, checksum: nil, readers: [])
@path = path
@checksum = checksum
@readers = readers
end

def processed?(path)
checksum == path.checksum
end

def replay(context)
@readers.each do |config|
key, name, id, path, lineno = config.values_at(:key, :name, :id, :path, :lineno)
context.send(key).instance_eval "def #{name}; find #{id}; end", path, lineno
end
end

def add_reader(key, name, id, location)
@readers << { key: key, name: name, id: id, path: location.path, lineno: location.lineno }
end

def to_h
{ path: @path, checksum: @checksum, readers: @readers.uniq }
def load_onto(seeds)
@entries.each do |entry|
@entry = entry
@entry.load_onto seeds
end
end
end

class Path
def initialize(context, path)
@context, @path = context, path
@source = path.read
end

def to_s
@path.to_s
end
def self.seeds(&block)
store_path.rmtree if ENV["OAKEN_RESET"]

def checksum
Digest::MD5.hexdigest @source
if block_given?
Seeds.instance_eval(&block)
else
Rails.application.load_seed
end

def process
@context.class_eval @source, to_s
end
Seeds
end
end

require_relative "oaken/railtie" if defined?(Rails::Railtie)
55 changes: 55 additions & 0 deletions lib/oaken/entry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
require "digest/md5"
require "pstore"

class Oaken::Entry < DelegateClass(PStore)
def self.store_accessor(name)
define_method(name) { self[name] } and define_method("#{name}=") { |value| self[name] = value }
end
store_accessor :checksum
store_accessor :readers

def self.within(directory)
Pathname.glob("#{directory}{,/**/*}.rb").sort.map { new _1 }
end

def initialize(pathname)
@file, @pathname = pathname.to_s, pathname
@computed_checksum = Digest::MD5.hexdigest(@pathname.read)

prepared_store_path = Oaken.store_path.join(pathname).tap { _1.dirname.mkpath }
super PStore.new(prepared_store_path)
end

def load_onto(seeds)
transaction do
if replay?
puts "Replaying #{@file}…"
readers.each do |key, name, id, lineno|
seeds.send(key).instance_eval "def #{name}; find #{id}; end", @file, lineno
end
else
reset
seeds.class_eval @pathname.read, @file
end
end
end

def replay?
checksum == @computed_checksum
end

def reset
self.checksum = @computed_checksum
self.readers = Set.new
end

def define_reader(stored, name, id)
lineno = self.lineno
stored.instance_eval "def #{name}; find #{id}; end", @file, lineno
readers << [stored.key, name, id, lineno]
end

def lineno
caller_locations(3, 10).find { _1.path == @file }.lineno
end
end
6 changes: 6 additions & 0 deletions lib/oaken/railtie.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Oaken::Railtie < Rails::Railtie
initializer "oaken.defaults" do
Oaken.lookup_paths << "db/seeds/#{Rails.env}"
Oaken.store_path = Oaken.store_path.join(Rails.env)
end
end
41 changes: 41 additions & 0 deletions lib/oaken/seeds.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module Oaken::Seeds
extend self

def self.respond_to_missing?(name, ...)
Oaken.inflector.classify(name).safe_constantize || super
end

def self.method_missing(name, ...)
name = name.to_s
if type = Oaken.inflector.classify(name).safe_constantize
register type, name
public_send(name, ...)
else
super
end
end

def self.register(type, key = nil)
stored = provider.new(type, key) and define_method(stored.key) { stored }
end
def self.provider = Oaken::Stored::ActiveRecord

singleton_class.attr_reader :loader
delegate :entry, to: :loader

module Loading
def seed(*directories)
Oaken.lookup_paths.each do |path|
directories.each do |directory|
@loader = Oaken::Loader.new Pathname(path).join(directory.to_s)
@loader.load_onto Oaken::Seeds
end
end
end
end
extend Loading

def self.included(klass)
klass.extend Loading
end
end
23 changes: 23 additions & 0 deletions lib/oaken/stored/active_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class Oaken::Stored::ActiveRecord < Struct.new(:type, :key)
def initialize(type, key = nil)
super(type, key || Oaken.inflector.tableize(type.name))
end
delegate :find, :insert_all, to: :type

def create(reader = nil, **attributes)
type.create!(**attributes).tap do |record|
define_reader reader, record.id if reader
end
end

def insert(reader = nil, **attributes)
type.new(attributes).validate!
type.insert(attributes).tap do
define_reader reader, type.where(attributes).pick(:id) if reader
end
end

def define_reader(name, id)
Oaken::Seeds.entry.define_reader(self, name, id)
end
end
2 changes: 2 additions & 0 deletions test/dummy/app/models/account.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Account < ApplicationRecord
has_many :administratorships
has_many :users, through: :administratorships

has_many :menus
end
4 changes: 4 additions & 0 deletions test/dummy/db/seeds.rb
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
Oaken.seeds do
register Menu::Item

seed :accounts, :data
end
1 change: 0 additions & 1 deletion test/dummy/db/seeds/accounts/kaspers_donuts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
coworker = users.create :coworker, name: "Coworker"
administratorships.create account: donuts, user: coworker

register Menu::Item
menu = menus.create account: donuts
plain_donut = menu_items.create menu: menu, name: "Plain", price_cents: 10_00
sprinkled_donut = menu_items.create menu: menu, name: "Sprinkled", price_cents: 10_10
Expand Down
4 changes: 4 additions & 0 deletions test/dummy/db/seeds/cases/pagination.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
item_id = accounts.kaspers_donuts.menus.first.items.pick(:id)

orders.insert_all \
[{ user_id: users.kasper.id, item_id: item_id }] * 100
1 change: 1 addition & 0 deletions test/dummy/db/seeds/production/data/plans.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
raise "This seed file executed outside of production" unless Rails.env.production?
1 change: 1 addition & 0 deletions test/dummy/db/seeds/test/data/plans.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
plans.insert :test_premium, title: "Premium", price_cents: 20_00
9 changes: 9 additions & 0 deletions test/dummy/test/integration/pagination_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "test_helper"

class PaginationTest < ActiveSupport::TestCase
seed "cases/pagination"

test "pagination sorta" do
assert_operator Order.count, :>=, 100
end
end
4 changes: 4 additions & 0 deletions test/dummy/test/models/oaken_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class OakenTest < ActiveSupport::TestCase
assert_equal [users.kasper, users.coworker], accounts.kaspers_donuts.users
end

test "accessing fixture from test env" do
assert plans.test_premium
end

test "source attribution" do
donuts_location, kasper_location = [accounts.method(:kaspers_donuts), users.method(:kasper)].map(&:source_location)
assert_match "db/seeds/accounts/kaspers_donuts.rb", donuts_location.first
Expand Down
5 changes: 1 addition & 4 deletions test/dummy/test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,11 @@

# ActiveRecord::Base.logger = Logger.new(STDOUT)

Oaken::Seeds.preregister ActiveRecord::Base.connection.tables.grep_v(/^ar_/)
Oaken::Seeds.load_from "db/seeds"

class ActiveSupport::TestCase
# Run tests in parallel with specified workers
parallelize workers: :number_of_processors

include Oaken::Seeds
include Oaken.seeds

# Override Minitest::Test#run to wrap each test in a transaction.
def run
Expand Down

0 comments on commit 505dd68

Please sign in to comment.