Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GUILD-690: Initial draft of new Active Record Consumer Generator #170

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## UNRELEASED

# 1.18.3 - 2022-11-16

### Features :star:

- Added ActiveRecordConsumerGenerator, which streamlines the process into a single flow
- Creates a database migration, Rails model, consumer, consumer config, and Deimos schema classes

# 1.18.2 - 2022-11-14

- Fixes bug related to wait between runs when no records are fetched by updating poll_info
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Built on Phobos and hence Ruby-Kafka.
* [Instrumentation](#instrumentation)
* [Kafka Message Keys](#kafka-message-keys)
* [Consumers](#consumers)
* [Active Record Consumer Generator](#active-record-consumer-generator)
* [Rails Integration](#rails-integration)
* [Controller Mixin](#controller-mixin)
* [Database Backend](#database-backend)
Expand Down Expand Up @@ -309,6 +310,49 @@ class MyConsumer < Deimos::Consumer
end
```

## Active Record Consumer Generator

Deimos provides a generator that streamlines Deimos consumer creation. It
creates all the necessary components needed in a Ruby on Rails application when
creating a new Active Record Consumer.

It will take an existing schema and generate the following files based on its fields:

Database Migration
Rails Model
Consumer Class
Deimos Consumer Config
Generated Schema Classes


By default, any complex sub-types (such as records or arrays) are turned into JSON
(if supported) or string columns.

Before running this generator, you must first copy the schema into your repo in the
correct path.

To generate a migration, model, consumer class, consumer config and schema classes, run the following:

Usage:

rails g deimos:active_record_consumer FULL_SCHEMA_NAME [CONFIG_FILE_PATH]

Options are...
FULL_SCHEMA_NAME (required) Fully qualified schema name, located in config.schema.path that defines the Avro schema for the Consumer.
CONFIG_FILE_PATH (optional) Path to existing config file. If not specified, defaults to config/initializers/deimos.rb.

Example:

rails g deimos:active_record_consumer com.my-namespace.Widget

...would generate:

db/migrate/20221111134112_create_widgets.rb
app/models/widget.rb
app/lib/kafka/models/widget_consumer.rb
config/initializers/deimos.rb
app/lib/schema_classes/**/*.rb

### Fatal Errors

The recommended configuration is for consumers *not* to raise errors
Expand Down
23 changes: 23 additions & 0 deletions lib/generators/deimos/active_record_consumer/USAGE
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Description:
Streamlines Deimos consumer creation. Creates all the necessary components needed in a Ruby on Rails application when creating a new Active Record Consumer.

Usage:
bin/rails generate deimos:active_record_consumer FULL_SCHEMA_NAME [CONFIG_FILE_PATH]

Options are...
FULL_SCHEMA_NAME (required) Fully qualified schema name, located in config.schema.path that defines the Avro schema for the Consumer.
CONFIG_FILE_PATH (optional) Path to existing config file. If not specified, defaults to config/initializers/deimos.rb.

Example:
bin/rails generate deimos:active_record_consumer com.test.Widget config/initializers/myconfig.config

This will create:
Database Migration db/migrate/20221111134112_create_widgets.rb
Rails Model app/models/widget.rb
Consumer Class app/lib/kafka/models/widget_consumer.rb
Deimos Consumer Config config/initializers/deimos.rb or adds config to CONFIG_FILE_PATH
Generated Schema Classes app/lib/schema_classes or config.schema.generated_class_path




Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@

Deimos.configure do
logger Logger.new(STDOUT)
# Nested config field
kafka.seed_brokers ['my.kafka.broker:9092']

schema.generate_namespace_folders true
schema.generated_class_path 'app/lib/schemas'

# Multiple nested config fields via block
consumers do
session_timeout 30
offset_commit_interval 10
end

# Define a consumer
consumer do
class_name '<%= consumer_name.classify %>'
topic 'TopicToConsume'
schema '<%= schema %>'
namespace '<%= namespace %>'
key_config field: :<%= key_field %>
# include Phobos / RubyKafka configs
start_from_beginning true
heartbeat_interval 10
use_schema_classes true
end

end
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
class <%= consumer_name.classify %> < Deimos::ActiveRecordConsumer
record_class <%= model_name.classify %>

# Optional override of the way to fetch records based on payload and
# key. Default is to use the key to search the primary key of the table.
# Only used in non-batch mode.
def fetch_record(klass, payload, key)
super
end

# Optional override on how to set primary key for new records.
# Default is to set the class's primary key to the message's decoded key.
# Only used in non-batch mode.
def assign_key(record, payload, key)
super
end

# Optional override of the default behavior, which is to call `destroy`
# on the record - e.g. you can replace this with "archiving" the record
# in some way.
# Only used in non-batch mode.
def destroy_record(record)
super
end

# Optional override to change the attributes of the record before they
# are saved.
def record_attributes(payload, key)
super.merge(:some_field => 'some_value')
end

# Optional override to change the attributes used for identifying records
def record_key(payload)
super
end

# Optional override, returns true by default.
# When this method returns true, a record corresponding to the message
# is created/updated.
# When this method returns false, message processing is skipped and a
# corresponding record will NOT be created/updated.
def process_message?(payload)
super
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we already have this template file? We should be able to reuse it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - better idea to reuse the existing code - will fix this.

def up
if table_exists?(:<%= table_name %>)
warn "<%= table_name %> already exists, exiting"
return
end
create_table :<%= table_name %> do |t|
<%- fields.each do |key| -%>
<%- next if %w(id message_id timestamp updated_at created_at).include?(key.name) -%>
<%- sql_type = schema_base.sql_type(key)
if %w(record array map).include?(sql_type)
conn = ActiveRecord::Base.connection
sql_type = conn.respond_to?(:supports_json?) && conn.supports_json? ? :json : :string
end
-%>
t.<%= sql_type %> :<%= key.name %>
<%- end -%>

t.timestamps

# TODO add indexes as necessary
end
end

def down
return unless table_exists?(:<%= table_name %>)
drop_table :<%= table_name %>
end

end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class <%= table_name.classify %> < ApplicationRecord
<%- fields.select { |f| f.enum_values.any? }.each do |field| -%>
enum <%= field.name %>: {<%= field.enum_values.map { |v| "#{v}: '#{v}'"}.join(', ') %>}
<% end -%>
end
151 changes: 151 additions & 0 deletions lib/generators/deimos/active_record_consumer_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# frozen_string_literal: true

require 'rails/generators'
require 'rails/generators/active_record/migration'
require 'generators/deimos/schema_class_generator'
require 'rails/version'
require 'erb'

# Generates a new Active Record Consumer, as well
# as all the necessary files and configuration.
# Streamlines the creation process into a single flow.
module Deimos
module Generators
# Generator for ActiveRecordConsumer db migration, Rails model, Consumer, Consumer config
# and Deimos Schema class
class ActiveRecordConsumerGenerator < Rails::Generators::Base
include Rails::Generators::Migration
if Rails.version < '4'
extend(ActiveRecord::Generators::Migration)
else
include ActiveRecord::Generators::Migration
end
source_root File.expand_path('active_record_consumer/templates', __dir__)

argument :full_schema, desc: 'The fully qualified schema name.', required: true
argument :config_path, desc: 'The path to the deimos configuration file, relative to the root directory.', required: false

no_commands do

# Creates database migration for creating new table
def create_db_migration
migration_template('migration.rb', "#{db_migrate_path}/create_#{table_name.underscore}.rb")
end

# Creates Rails Model
def create_rails_model
template('model.rb', "app/models/#{model_name}.rb")
end

# Creates Kafka Consumer file
def create_consumer
template('consumer.rb', "app/lib/kafka/models/#{consumer_name.underscore}.rb")
end

# Adds consumer config to config file.
# Defaults to deimos.rb if config_path arg is not specified.
def create_consumer_config
config_file = 'deimos.rb'
config_file_path = "#{initializer_path}/#{config_file}"
config_file_path = config_path if config_path.present?

if File.exist?(config_file_path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should validate that the config file exists if it's passed in. If not, we can create it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I tested it by passing in a config file path that did not already exist, it did go ahead and create that config file.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, that was unclear. We should only create it if no path was specified. If a path was specified and doesn't exist, we should assume that means that they made a mistake and raise an error.

Actually, I'm not sure we need to create it at all. If we want to auto-create a config file, it probably would be in an installer or something, but I don't think we have a use case for that. So maybe I'd just say don't create anything, and throw an error if the file doesn't exist.

config_template = File.expand_path(find_in_source_paths('config.rb'))
insert_into_file(config_file_path.to_s, CapturableERB.new(::File.binread(config_template)).result(binding))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this insert the whole file? We need a way to just insert the consumer do block.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I will change it to just insert the consumer do block. Wasn't sure if we needed to add the other config.

else
template('config.rb', config_file_path.to_s)
end
end

# Generates schema classes
def create_deimos_schema_class
Deimos::Generators::SchemaClassGenerator.start
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be a way to generate just the one schema class we care about.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which is the one schema class that we care about?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one that was passed in as an input.

end

# @return [String]
def db_migrate_path
if defined?(Rails.application) && Rails.application
paths = Rails.application.config.paths['db/migrate']
paths.respond_to?(:to_ary) ? paths.to_ary.first : paths.to_a.first
else
'db/migrate'
end
end

# @return [String]
def migration_version
"[#{ActiveRecord::Migration.current_version}]"
rescue StandardError
''
end

# @return [String]
def table_class
table_name.classify
end

# @return [String]
def schema
last_dot = self.full_schema.rindex('.')
self.full_schema[last_dot + 1..-1]
end

# @return [String]
def namespace
last_dot = self.full_schema.rindex('.')
self.full_schema[0...last_dot]
end

# @return [Deimos::SchemaBackends::Base]
def schema_base
@schema_base ||= Deimos.schema_backend_class.new(schema: schema, namespace: namespace)
end

# @return [Array<SchemaField>]
def fields
schema_base.schema_fields
end

# @return [String]
def table_name
schema.tableize
end

# @return [String]
def model_name
table_name.underscore.singularize
end

# @return [String]
def consumer_name
"#{schema.classify}Consumer"
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of this is copied from the migration template. There should be a way to just call the migration generator from this generator rather than having the same code twice.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered extending from the migration template but decided it wasn't the right approach. Will find a better way!


# @return [String]
def initializer_path
if defined?(Rails.application) && Rails.application
paths = Rails.application.config.paths['config/initializers']
paths.respond_to?(:to_ary) ? paths.to_ary.first : paths.to_a.first
else
'config/initializers'
end
end

# @return [String] Returns the name of the first field in the schema, as the key
def key_field
fields.first.name
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is almost definitely not going to be the right approach. It probably makes more sense to just have the key config specified as part of the input. We'll also need the key schema (if there is one) when generating the schema classes.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, yes makes sense to include key config as an input.

In terms of the key schema, how does that work? I noticed that the schema glasses generator doesn't take in any arguments. So, if the user has a key schema for the new consumer, do they just have to place it in the SCHEMA_ROOT prior to running the ActiveRecordConsumer generator? And then what will be the difference in output? Will the generated schema classes be different?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator reads the configuration which already knows what the key schema is.

The generator has a method generate_classes(schema_name, namespace, key_config) which you should probably be using.

end
end

# desc 'Generate necessary files and configuration for a new Active Record Consumer.'
# @return [void]
def generate
create_db_migration
create_rails_model
create_consumer
create_consumer_config
create_deimos_schema_class
end
end
end
end
Loading