Set constraints on Readouts.user_id foreign key

Add Measurements table
This commit is contained in:
2026-05-05 20:33:21 +02:00
parent 4a36ffc8bf
commit d3a34233b9
10 changed files with 107 additions and 22 deletions

View File

@@ -34,10 +34,10 @@ whenever a change is considered, to avoid regressions.
custom `BigDecimal` type custom `BigDecimal` type
### Database layer vs application layer data model constraints ### Database layer vs application layer data model constraints
* database constraints are the final frontier against data corruption, * database constraints are the final guard against data integrity corruption,
* they should safeguard against data _consistency_ loss under _all_ data * they should safeguard against data referential integrity loss under _all_
(not schema) manipulation scenarios, including application level logic data (not schema) manipulation scenarios, including application level
errors and direct data manipulation, logic errors and direct data manipulation (e.g. through `dbconsole`),
* application constraints can be as restrictive as database constraints or more, * application constraints can be as restrictive as database constraints or more,
but not less, as it doesn't serve any use case, but not less, as it doesn't serve any use case,
* proper application level constraints should prevent unhandled database * proper application level constraints should prevent unhandled database

View File

@@ -144,6 +144,10 @@ below shows how to grant privileges to all databases which names start with
mysql> grant all privileges on `fixinme-%`.* to `fixinme-dev`@localhost; mysql> grant all privileges on `fixinme-%`.* to `fixinme-dev`@localhost;
mysql> flush privileges; mysql> flush privileges;
Dumping development data before database reset:
mysqldump -h <address> -u <user> -p --no-create-info --no-tablespaces --complete-insert <database> > tmp/data.sql
### Development environment ### Development environment
Starting application server in development environment: Starting application server in development environment:

View File

@@ -6,7 +6,7 @@ class ReadoutsController < ApplicationController
def new def new
@quantities -= @prev_quantities @quantities -= @prev_quantities
# TODO: raise ParameterInvalid if new_quantities.empty? # TODO: raise ParameterInvalid if new_quantities.empty?
@readouts = current_user.readouts.build(@quantities.map { |q| {quantity: q} }) @readouts = @quantities.map { |q| q.readouts.build }
@user_units = current_user.units.ordered @user_units = current_user.units.ordered

View File

@@ -6,6 +6,7 @@ class Quantity < ApplicationRecord
belongs_to :parent, optional: true, class_name: "Quantity" belongs_to :parent, optional: true, class_name: "Quantity"
has_many :subquantities, ->{ order(:name) }, class_name: "Quantity", has_many :subquantities, ->{ order(:name) }, class_name: "Quantity",
inverse_of: :parent, dependent: :restrict_with_error inverse_of: :parent, dependent: :restrict_with_error
has_many :readouts, dependent: :restrict_with_error
validate if: ->{ parent.present? } do validate if: ->{ parent.present? } do
errors.add(:parent, :user_mismatch) unless user_id == parent.user_id errors.add(:parent, :user_mismatch) unless user_id == parent.user_id

View File

@@ -4,4 +4,6 @@ class Readout < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :quantity belongs_to :quantity
belongs_to :unit belongs_to :unit
# TODO: validate quantity.user_id == unit.user_id != NULL
end end

View File

@@ -10,5 +10,6 @@ class CreateUnits < ActiveRecord::Migration[8.1]
t.timestamps t.timestamps
end end
add_index :units, [:user_id, :symbol] add_index :units, [:user_id, :symbol]
add_index :units, [:id, :user_id]
end end
end end

View File

@@ -13,5 +13,6 @@ class CreateQuantities < ActiveRecord::Migration[8.1]
t.string :pathname, null: false, limit: 511 t.string :pathname, null: false, limit: 511
end end
add_index :quantities, [:user_id, :parent_id, :name] add_index :quantities, [:user_id, :parent_id, :name]
add_index :quantities, [:id, :user_id]
end end
end end

View File

@@ -1,16 +1,63 @@
class CreateReadouts < ActiveRecord::Migration[8.1] class CreateReadouts < ActiveRecord::Migration[8.1]
def change def change
create_table :readouts do |t| create_table :measurements do |t|
t.references :user, null: false, foreign_key: {on_delete: :cascade} t.datetime :taken_at, null: false
t.references :quantity, null: false, foreign_key: true
# :category + :value + :unit as a separate table? (NumericValue, TextValue)
t.integer :category, null: false, default: 0
t.float :value, null: false, limit: Float::MANT_DIG
t.references :unit, foreign_key: true
#t.references :collector, foreign_key: true #t.references :collector, foreign_key: true
#t.references :device, foreign_key: true #t.references :device, foreign_key: true
#t.references :note, foreign_key: true
t.timestamps t.timestamps
end end
add_index :measurements, :taken_at
# Defining Readouts as a super-/subclass polymorphic relations for different
# subclass data types (numeric, string, file) is not possible with proper
# referential integrity constraints. The required constraints are:
# * for every subclass record to have superclass record,
# * for every superclass record to have only one type of subclass record,
# * for every superclass record to have subclass record (unenforcable).
# * this can be partially remedied by making superlass an abstract class in
# Rails and disallow direct creation of records, but direct data
# manipulation can still break referential integrity.
# Defining separate {Numeric,Text,File}_Readouts tables would make the
# unique index constraint unenforcable.
create_table :readouts do |t|
t.references :user, null: false, foreign_key: {on_delete: :cascade}
t.references :measurement, foreign_key: {on_delete: :cascade}
t.references :quantity, null: false, foreign_key: {on_delete: :cascade}
t.integer :category, null: false, default: 0
t.float :value, null: false, limit: Float::MANT_DIG
t.references :unit, null: false, foreign_key: {on_delete: :cascade}
# TODO: consider additional columns to allow wider range of value types
# t.text :text
# t.datetime :time
# t.references :file
# Possibly mutually exclusive with :unit or check constraint for:
# :unit is not null <=> :value is not null
t.timestamps
end
add_index :readouts, [:measurement_id, :quantity_id, :category]
add_foreign_key :readouts, :quantities, column: [:quantity_id, :user_id],
primary_key: [:id, :user_id]
add_foreign_key :readouts, :units, column: [:unit_id, :user_id],
primary_key: [:id, :user_id]
# TODO: remove below tables after current setup verified
#create_table :numeric_values do |t|
# t.references :readout, null: false, foreign_key: {on_delete: :cascade}
# t.float :value, null: false, limit: Float::MANT_DIG
# t.references :unit, null: false, foreign_key: {on_delete: :cascade}
# # + generated, not stored column :value_type
# # + foreign key constraint to readouts: [:readout_id, :value_id, :value_type]
# # or 2 constraints: [:readout_id, :value_id], [:value_id, :value_type]
# # if readouts.value_id needed, otherwise just one constraint:
# # [:readout_id, :value_type]
#end
#create_table :string_values do |t|
# t.references :readout, null: false, foreign_key: {on_delete: :cascade}
# t.string :value, null: false, limit: 32
#end
end end
end end

View File

@@ -10,7 +10,14 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
create_table "measurements", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.datetime "taken_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["taken_at"], name: "index_measurements_on_taken_at", unique: true
end
create_table "quantities", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t| create_table "quantities", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id" t.bigint "user_id"
t.string "name", limit: 31, null: false t.string "name", limit: 31, null: false
@@ -20,6 +27,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "depth", default: 0, null: false t.integer "depth", default: 0, null: false
t.string "pathname", limit: 511, null: false t.string "pathname", limit: 511, null: false
t.index ["id", "user_id"], name: "index_quantities_on_id_and_user_id", unique: true
t.index ["parent_id"], name: "index_quantities_on_parent_id" t.index ["parent_id"], name: "index_quantities_on_parent_id"
t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true
t.index ["user_id"], name: "index_quantities_on_user_id" t.index ["user_id"], name: "index_quantities_on_user_id"
@@ -27,14 +35,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do
create_table "readouts", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t| create_table "readouts", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.bigint "measurement_id"
t.bigint "quantity_id", null: false t.bigint "quantity_id", null: false
t.integer "category", default: 0, null: false t.integer "category", default: 0, null: false
t.float "value", limit: 53, null: false t.float "value", limit: 53, null: false
t.bigint "unit_id" t.bigint "unit_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["quantity_id", "created_at"], name: "index_readouts_on_quantity_id_and_created_at", unique: true t.index ["measurement_id", "quantity_id", "category"], name: "index_readouts_on_measurement_id_and_quantity_id_and_category", unique: true
t.index ["measurement_id"], name: "index_readouts_on_measurement_id"
t.index ["quantity_id", "user_id"], name: "fk_rails_9d92eaafc6"
t.index ["quantity_id"], name: "index_readouts_on_quantity_id" t.index ["quantity_id"], name: "index_readouts_on_quantity_id"
t.index ["unit_id", "user_id"], name: "fk_rails_348b0fb4c5"
t.index ["unit_id"], name: "index_readouts_on_unit_id" t.index ["unit_id"], name: "index_readouts_on_unit_id"
t.index ["user_id"], name: "index_readouts_on_user_id" t.index ["user_id"], name: "index_readouts_on_user_id"
end end
@@ -48,6 +60,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["base_id"], name: "index_units_on_base_id" t.index ["base_id"], name: "index_units_on_base_id"
t.index ["id", "user_id"], name: "index_units_on_id_and_user_id", unique: true
t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true
t.index ["user_id"], name: "index_units_on_user_id" t.index ["user_id"], name: "index_units_on_user_id"
end end
@@ -72,8 +85,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do
add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
add_foreign_key "quantities", "users", on_delete: :cascade add_foreign_key "quantities", "users", on_delete: :cascade
add_foreign_key "readouts", "quantities" add_foreign_key "readouts", "measurements", on_delete: :cascade
add_foreign_key "readouts", "units" add_foreign_key "readouts", "quantities", column: ["quantity_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "quantities", on_delete: :cascade
add_foreign_key "readouts", "units", column: ["unit_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "units", on_delete: :cascade
add_foreign_key "readouts", "users", on_delete: :cascade add_foreign_key "readouts", "users", on_delete: :cascade
add_foreign_key "units", "units", column: "base_id", on_delete: :cascade add_foreign_key "units", "units", column: "base_id", on_delete: :cascade
add_foreign_key "units", "users", on_delete: :cascade add_foreign_key "units", "users", on_delete: :cascade

View File

@@ -10,7 +10,13 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
create_table "measurements", force: :cascade do |t|
t.datetime "taken_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "quantities", force: :cascade do |t| create_table "quantities", force: :cascade do |t|
t.integer "user_id" t.integer "user_id"
t.string "name", limit: 31, null: false t.string "name", limit: 31, null: false
@@ -20,6 +26,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "depth", default: 0, null: false t.integer "depth", default: 0, null: false
t.string "pathname", limit: 511, null: false t.string "pathname", limit: 511, null: false
t.index ["id", "user_id"], name: "index_quantities_on_id_and_user_id", unique: true
t.index ["parent_id"], name: "index_quantities_on_parent_id" t.index ["parent_id"], name: "index_quantities_on_parent_id"
t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true
t.index ["user_id"], name: "index_quantities_on_user_id" t.index ["user_id"], name: "index_quantities_on_user_id"
@@ -27,13 +34,15 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do
create_table "readouts", force: :cascade do |t| create_table "readouts", force: :cascade do |t|
t.integer "user_id", null: false t.integer "user_id", null: false
t.integer "measurement_id"
t.integer "quantity_id", null: false t.integer "quantity_id", null: false
t.integer "category", default: 0, null: false t.integer "category", default: 0, null: false
t.float "value", limit: 53, null: false t.float "value", limit: 53, null: false
t.integer "unit_id" t.integer "unit_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["quantity_id", "created_at"], name: "index_readouts_on_quantity_id_and_created_at", unique: true t.index ["measurement_id", "quantity_id", "category"], name: "index_readouts_on_measurement_id_and_quantity_id_and_category", unique: true
t.index ["measurement_id"], name: "index_readouts_on_measurement_id"
t.index ["quantity_id"], name: "index_readouts_on_quantity_id" t.index ["quantity_id"], name: "index_readouts_on_quantity_id"
t.index ["unit_id"], name: "index_readouts_on_unit_id" t.index ["unit_id"], name: "index_readouts_on_unit_id"
t.index ["user_id"], name: "index_readouts_on_user_id" t.index ["user_id"], name: "index_readouts_on_user_id"
@@ -48,6 +57,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["base_id"], name: "index_units_on_base_id" t.index ["base_id"], name: "index_units_on_base_id"
t.index ["id", "user_id"], name: "index_units_on_id_and_user_id", unique: true
t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true
t.index ["user_id"], name: "index_units_on_user_id" t.index ["user_id"], name: "index_units_on_user_id"
end end
@@ -72,8 +82,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_21_230456) do
add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
add_foreign_key "quantities", "users", on_delete: :cascade add_foreign_key "quantities", "users", on_delete: :cascade
add_foreign_key "readouts", "quantities" add_foreign_key "readouts", "measurements", on_delete: :cascade
add_foreign_key "readouts", "units" add_foreign_key "readouts", "quantities", column: ["quantity_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "quantities", on_delete: :cascade
add_foreign_key "readouts", "units", column: ["unit_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "units", on_delete: :cascade
add_foreign_key "readouts", "users", on_delete: :cascade add_foreign_key "readouts", "users", on_delete: :cascade
add_foreign_key "units", "units", column: "base_id", on_delete: :cascade add_foreign_key "units", "units", column: "base_id", on_delete: :cascade
add_foreign_key "units", "users", on_delete: :cascade add_foreign_key "units", "users", on_delete: :cascade