pay-rails / pay

Payments for Ruby on Rails apps
https://github.com/pay-rails/pay
MIT License
1.86k stars 301 forks source link

MariaDB doesn't not support JSON type. Undefined method 'accessor' #425

Closed deanpcmad closed 2 years ago

deanpcmad commented 2 years ago

I've just tried to set Pay v3 up on my Rails application (and I've also created a basic Rails app to test this error) but I'm running into issues.

customer = Customer.create email: "test@test.com
customer.set_payment_processor :stripe

customer.payment_processor
#<Pay::Customer id: 1, owner_type: "Customer", owner_id: 1, processor: "stripe", processor_id: nil, default: true, data: nil, deleted_at: nil, created_at: "2021-09-08 12:56:25.966822000 +0000", updated_at: "2021-09-08 12:56:25.966822000 +0000", plan: nil, quantity: nil, payment_method_token: nil>

customer.payment_processor.customer
# NoMethodError (undefined method `accessor' for #<ActiveRecord::Type::Text:0x00005634e2aebd08>)

When using the Fake Processor, it works fine:

Customer.first.set_payment_processor :fake_processor, allow_fake: true
#<Pay::Customer id: 2, owner_type: "Customer", owner_id: 1, processor: "fake_processor", processor_id: "c3X0samQM26S1uBaKBYVy", default: true, data: nil, deleted_at: nil, created_at: "2021-09-08 12:58:06.764957000 +0000", updated_at: "2021-09-08 13:00:20.902029000 +0000", plan: nil, quantity: nil, payment_method_token: nil>

Customer.first.payment_processor.customer
#<Pay::Customer id: 2, owner_type: "Customer", owner_id: 1, processor: "fake_processor", processor_id: "c3X0samQM26S1uBaKBYVy", default: true, data: nil, deleted_at: nil, created_at: "2021-09-08 12:58:06.764957000 +0000", updated_at: "2021-09-08 13:00:20.902029000 +0000", plan: nil, quantity: nil, payment_method_token: nil>

I'm using Rails 6.1.4.1 with MariaDB 10.5.12. I've also tried MariaDB 10.6.4.

Any ideas?

excid3 commented 2 years ago

You have a stacktrace I can see?

deanpcmad commented 2 years ago

That's the thing, it doesn't give a proper stacktrace. I've attached a screenshot:

Screenshot from 2021-09-08 15-05-36

excid3 commented 2 years ago

The console truncates it, but you can always get a backtrace.

begin
  mycode
rescue => exception
  puts exception.backtrace
end

That should print it all out. 👍

deanpcmad commented 2 years ago

Ah, here we go:

/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/store.rb:217:in `store_accessor_for'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/store.rb:207:in `read_store_attribute'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/store.rb:140:in `block (3 levels) in store_accessor'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/pay-3.0.11/lib/pay/stripe/billable.rb:8:in `stripe_account'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/pay-3.0.11/lib/pay/stripe/billable.rb:235:in `stripe_options'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/pay-3.0.11/lib/pay/stripe/billable.rb:29:in `customer'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/activesupport-6.1.4.1/lib/active_support/core_ext/module/delegation.rb:310:in `public_send'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/activesupport-6.1.4.1/lib/active_support/core_ext/module/delegation.rb:310:in `method_missing'
(irb):8:in `rescue in irb_binding'
(irb):6:in `irb_binding'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb/workspace.rb:114:in `eval'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb/workspace.rb:114:in `evaluate'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb/context.rb:459:in `evaluate'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb.rb:541:in `block (2 levels) in eval_input'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb.rb:704:in `signal_status'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb.rb:538:in `block in eval_input'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb/ruby-lex.rb:166:in `block (2 levels) in each_top_level_statement'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb/ruby-lex.rb:151:in `loop'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb/ruby-lex.rb:151:in `block in each_top_level_statement'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb/ruby-lex.rb:150:in `catch'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb/ruby-lex.rb:150:in `each_top_level_statement'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb.rb:537:in `eval_input'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb.rb:472:in `block in run'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb.rb:471:in `catch'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb.rb:471:in `run'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/2.7.0/irb.rb:400:in `start'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/railties-6.1.4.1/lib/rails/commands/console/console_command.rb:70:in `start'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/railties-6.1.4.1/lib/rails/commands/console/console_command.rb:19:in `start'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/railties-6.1.4.1/lib/rails/commands/console/console_command.rb:102:in `perform'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/thor-1.1.0/lib/thor/command.rb:27:in `run'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/thor-1.1.0/lib/thor/invocation.rb:127:in `invoke_command'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/thor-1.1.0/lib/thor.rb:392:in `dispatch'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/railties-6.1.4.1/lib/rails/command/base.rb:69:in `perform'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/railties-6.1.4.1/lib/rails/command.rb:48:in `invoke'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/railties-6.1.4.1/lib/rails/commands.rb:18:in `<main>'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/bootsnap-1.8.1/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `require'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/bootsnap-1.8.1/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `block in require_with_bootsnap_lfi'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/bootsnap-1.8.1/lib/bootsnap/load_path_cache/loaded_features_index.rb:92:in `register'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/bootsnap-1.8.1/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:22:in `require_with_bootsnap_lfi'
/home/dean/.asdf/installs/ruby/2.7.4/lib/ruby/gems/2.7.0/gems/bootsnap-1.8.1/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:31:in `require'
bin/rails:5:in `<main>'
excid3 commented 2 years ago

Perfect, thanks!

It looks like your pay_customers table is missing the data json column. Can you check your table?

Maybe one of the migrations is wrong.

deanpcmad commented 2 years ago

I just used the standard migration installer: bin/rails pay:install:migrations

Here's my schema:

# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2021_09_08_125550) do

  create_table "customers", charset: "utf8mb4", force: :cascade do |t|
    t.string "email"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  create_table "pay_charges", charset: "utf8mb4", force: :cascade do |t|
    t.bigint "customer_id", null: false
    t.bigint "subscription_id"
    t.string "processor_id", null: false
    t.integer "amount", null: false
    t.string "currency"
    t.integer "application_fee_amount"
    t.integer "amount_refunded"
    t.text "metadata", size: :long, collation: "utf8mb4_bin"
    t.text "data", size: :long, collation: "utf8mb4_bin"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["customer_id", "processor_id"], name: "index_pay_charges_on_customer_id_and_processor_id", unique: true
    t.index ["subscription_id"], name: "index_pay_charges_on_subscription_id"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`metadata`)", name: "metadata"
    t.check_constraint "json_valid(`metadata`)", name: "metadata"
  end

  create_table "pay_customers", charset: "utf8mb4", force: :cascade do |t|
    t.string "owner_type"
    t.bigint "owner_id"
    t.string "processor", null: false
    t.string "processor_id"
    t.boolean "default"
    t.text "data", size: :long, collation: "utf8mb4_bin"
    t.datetime "deleted_at"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["owner_type", "owner_id", "deleted_at", "default"], name: "pay_customer_owner_index"
    t.index ["processor", "processor_id"], name: "index_pay_customers_on_processor_and_processor_id", unique: true
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
  end

  create_table "pay_merchants", charset: "utf8mb4", force: :cascade do |t|
    t.string "owner_type"
    t.bigint "owner_id"
    t.string "processor", null: false
    t.string "processor_id"
    t.boolean "default"
    t.text "data", size: :long, collation: "utf8mb4_bin"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["owner_type", "owner_id", "processor"], name: "index_pay_merchants_on_owner_type_and_owner_id_and_processor"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
  end

  create_table "pay_payment_methods", charset: "utf8mb4", force: :cascade do |t|
    t.bigint "customer_id", null: false
    t.string "processor_id", null: false
    t.boolean "default"
    t.string "type"
    t.text "data", size: :long, collation: "utf8mb4_bin"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["customer_id", "processor_id"], name: "index_pay_payment_methods_on_customer_id_and_processor_id", unique: true
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
  end

  create_table "pay_subscriptions", charset: "utf8mb4", force: :cascade do |t|
    t.bigint "customer_id", null: false
    t.string "name", null: false
    t.string "processor_id", null: false
    t.string "processor_plan", null: false
    t.integer "quantity", default: 1, null: false
    t.string "status", null: false
    t.datetime "trial_ends_at"
    t.datetime "ends_at"
    t.decimal "application_fee_percent", precision: 8, scale: 2
    t.text "metadata", size: :long, collation: "utf8mb4_bin"
    t.text "data", size: :long, collation: "utf8mb4_bin"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["customer_id", "processor_id"], name: "index_pay_subscriptions_on_customer_id_and_processor_id", unique: true
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`data`)", name: "data"
    t.check_constraint "json_valid(`metadata`)", name: "metadata"
    t.check_constraint "json_valid(`metadata`)", name: "metadata"
  end

  create_table "pay_webhooks", charset: "utf8mb4", force: :cascade do |t|
    t.string "processor"
    t.string "event_type"
    t.text "event", size: :long, collation: "utf8mb4_bin"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.check_constraint "json_valid(`event`)", name: "event"
  end

  add_foreign_key "pay_charges", "pay_customers", column: "customer_id"
  add_foreign_key "pay_charges", "pay_subscriptions", column: "subscription_id"
  add_foreign_key "pay_payment_methods", "pay_customers", column: "customer_id"
  add_foreign_key "pay_subscriptions", "pay_customers", column: "customer_id"
end
excid3 commented 2 years ago

Ah, yeah your data column is not json.

t.text "data", size: :long, collation: "utf8mb4_bin"

You're using MySQL? It supports a json column type doesn't it? I run my tests against MySQL and it uses a json column fine.

deanpcmad commented 2 years ago

Hm. Even when setting the column type in the migration, it still does the same thing 🤔

excid3 commented 2 years ago

I guess maybe MYSQL uses this to validate that it's a JSON column.

t.check_constraint "json_valid(`data`)", name: "data"

Tests run on MySQL 8: https://github.com/pay-rails/pay/blob/master/.github/workflows/ci.yml#L97

Which version are you on?

deanpcmad commented 2 years ago

I'm using MariaDB 10.6.4, which should support it 🤔

excid3 commented 2 years ago

Do you use the mysql2 gem or something else for mariadb?

deanpcmad commented 2 years ago

Yep, mysql2. Everything else works fine so I'd rather not move all my apps back to normal MySQL. Is there a reason you're using the JSON field and not just a normal data field? I've used serialize multiple times with no issues on a data field, plus it works on different database types

deanpcmad commented 2 years ago

Having a quick Google, I've found this:

- MariaDB stores JSON as true text, not in binary format as MySQL. MariaDB's JSON functions are much faster than MySQL's so there is no need to store in binary format, which would add complexity when manipulating JSON objects.
- For the same reason, MariaDB's JSON data type is an alias for LONGTEXT. If you want to replicate JSON columns from MySQL to MariaDB, you should store JSON objects in MySQL in a TEXT or LONGTEXT column or use statement based replication. If you are using JSON columns and want to upgrade to MariaDB, you need to either convert them to TEXT or use mysqldump to copy these tables to MariaDB

I still think Pay should work with any database type, especially seeing as most people are using MariaDB these days.

I just tried setting them as text in the migration and it still errors out which makes me think it's a bug with Rails?

excid3 commented 2 years ago

That's unfortunate that MariaDB has implemented them as text columns. Rails will see them as text and not json. The store_accessor feature only applies to columns that Rails considers json in the schema.

Seems like Rails could be improved to treat them as json, but I'm not sure how you'd reasonably detect that.

excid3 commented 2 years ago

For reference: https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L70-L72

deanpcmad commented 2 years ago

Ah, good find. Well I've managed to get it working by using store which I think uses a Hash by default and all the model tests pass

store :data, accessors: [:stripe_connect_account_id, :onboarding_complete]
excid3 commented 2 years ago

Ah yeah, I didn't think of trying that for some reason. I just tried attribute :data, :json and it didn't work.

I guess the solution might be to add store :data if it's a text column, and then we can leave the store_accessor for the attributes and I bet that works.

Will add a test and confirm that works. Then it'll probably be good to add Maria to the CI.

excid3 commented 2 years ago

429