{}const=>[]async()letfn</>var
開発SQL

データベースの関係:1対多と多対多

リレーショナルデータベースの基本的な関係の種類を理解します。1対多および多対多の関係を正しく設計し、中間テーブルを作成し、外部キーを操作し、クエリを最適化する方法を学びます。SQLとTypeORMの例、典型的なエラーの解析、開発者向けのベストプラクティス。

К

Kodik

著者

5分で読める

そもそもなぜコネクションが必要なのでしょうか?

オンライン ストアを開発していると想像してみてください。注文するユーザーがいます。もちろん、すべてのデータを 1 つのテーブルに保存し、各注文でユーザー情報を複製することもできます。しかし、これはいくつかの理由から悪い考えです。

まず、データを複製します。ユーザーのメールアドレスまたは住所が変更された場合は、すべての注文のレコードを更新する必要があります。第二に、データベース内でより多くのスペースを占有します。第三に、ミスを犯しやすく、一貫性のないデータを取得しやすくなります。

関係は、データを個別のテーブルに保存し、外部キーを介してそれらを関連付けることで、この問題を解決します。

🔥 10万人以上の学生が参加中

理論を読むのに疲れた?
コーディングの時間だ!

Kodik — 実践でプログラミングを学ぶアプリ。AIメンター、インタラクティブなレッスン、実際のプロジェクト。

🤖 AI 24時間
🎓 修了証
💰 無料
🚀 始める
今日参加

1対多の関係

これは、データベースで最も一般的な関係の種類です。要するに、最初のテーブルの1つのレコードは2番目のテーブルの複数のレコードに関連付けることができますが、2番目のテーブルのレコードは最初のテーブルの1つのレコードにのみ関連付けられます。

典型的な例

ユーザーと注文。 1 人のユーザーが複数の注文を行うことができますが、各注文は 1 人のユーザーにのみ属します。

カテゴリと商品。 1 つのカテゴリに多くの商品を含めることができますが、各商品は 1 つのカテゴリにのみ属します。

著者と記事。 1人の著者が多くの記事を書くことができますが、各記事には1人の著者がいます。

SQLでの実装

ユーザーと注文を関連付けるためのテーブルを作成しましょう。

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(50) DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

ここでは、テーブルordersuser_idは、テーブルusersidを参照する外部キーです。パラメータ ON DELETE CASCADE は、ユーザーを削除すると、そのすべての注文も自動的に削除されることを意味します。

データの取り扱い

ユーザーとそのいくつかの注文を追加します。

INSERT INTO users (name, email) 
VALUES ('Alexey Ivanov', 'alexey@example.com');

INSERT INTO orders (user_id, total_amount, status) 
VALUES 
    (1, 1500.00, 'completed'),
    (1, 2300.50, 'pending'),
    (1, 890.00, 'completed');

ユーザーのデータとともにすべての注文を取得するには、JOINを使用します。

SELECT 
    users.name,
    users.email,
    orders.id as order_id,
    orders.total_amount,
    orders.status
FROM users
INNER JOIN orders ON users.id = orders.user_id
WHERE users.id = 1;

アプリでの作業

TypeORMや SequelizeなどのORMを使用している場合、1対多の関係は宣言的に構成されます。

// TypeORM
@Entity()
class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @Column()
    email: string;

    @OneToMany(() => Order, order => order.user)
    orders: Order[];
}

@Entity()
class Order {
    @PrimaryGeneratedColumn()
    id: number;

    @Column('decimal')
    totalAmount: number;

    @Column()
    status: string;

    @ManyToOne(() => User, user => user.orders)
    user: User;

    @Column()
    userId: number;
}

これで、すべての注文を持つユーザーを簡単に取得できます。

const user = await userRepository.findOne({
    where: { id: 1 },
    relations: ['orders']
});

console.log(user.orders); // すべてのユーザー注文の配列

多対多の関係

多対多の関係は少し複雑です。ここでは、最初のテーブルの1つのレコードを2番目のテーブルの複数のレコードに関連付けることができ、その逆も可能です。

典型的なシナリオ

学生とコース。 1人の学生が複数のコースに登録することができ、1つのコースで多くの学生が学ぶことができます。

商品とタグ。 1 つの商品に複数のタグを付けることができ、1 つのタグを複数の商品に適用することができます。

ユーザーとロール。 1 人のユーザーに複数のロールを割り当てることができ、1 つのロールを複数のユーザーに割り当てることができます。

中間表

リレーショナルデータベースでは、多対多の関係は、両方のリンクされたテーブルの外部キーを含む中間テーブルを介して実装されます。

商品のタグシステムを作成しましょう。

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    price DECIMAL(10, 2) NOT NULL,
    description TEXT
);

CREATE TABLE tags (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL
);

CREATE TABLE product_tags (
    product_id INTEGER NOT NULL,
    tag_id INTEGER NOT NULL,
    PRIMARY KEY (product_id, tag_id),
    FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);

テーブル product_tags は中間テーブルです。商品とタグを関連付けます。複合主キーに注意してください。product_idtag_idの組み合わせは一意である必要があります。これにより、同じタグが商品に2回追加されるのを防ぎます。

データの追加

-- Добавляем товары
INSERT INTO products (name, price, description) 
VALUES 
    ('MacBook Pro', 150000, 'Laptop for developers'),
    ('iPhone 15', 80000, 'Latest generation smartphone'),
    ('iPad Air', 60000, 'Work tablet');

-- Добавляем теги
INSERT INTO tags (name) 
VALUES 
    ('Apple'),
    ('Electronics'),
    ('For work'),
    ('Premium');

-- Связываем товары с тегами
INSERT INTO product_tags (product_id, tag_id) 
VALUES 
    (1, 1), -- MacBook Pro - Apple
    (1, 2), -- MacBook Pro - Электроника
    (1, 3), -- MacBook Pro - Для работы
    (1, 4), -- MacBook Pro - Премиум
    (2, 1), -- iPhone 15 - Apple
    (2, 2), -- iPhone 15 - Электроника
    (2, 4); -- iPhone 15 - Премиум

データへのクエリ

特定のタグを持つすべての商品を取得します。

SELECT products.*
FROM products
INNER JOIN product_tags ON products.id = product_tags.product_id
INNER JOIN tags ON product_tags.tag_id = tags.id
WHERE tags.name = 'Apple';

特定の商品のすべてのタグを取得します。

SELECT tags.*
FROM tags
INNER JOIN product_tags ON tags.id = product_tags.tag_id
WHERE product_tags.product_id = 1;

2つの特定のタグを持つ商品を検索します。

SELECT products.*
FROM products
WHERE id IN (
    SELECT product_id 
    FROM product_tags
    INNER JOIN tags ON product_tags.tag_id = tags.id
    WHERE tags.name IN ('Apple', 'For work')
    GROUP BY product_id
    HAVING COUNT(DISTINCT tags.id) = 2
);

ORMと多対多

ORM を使用すると、このような関係を扱いやすくなります。

@Entity()
class Product {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @Column('decimal')
    price: number;

    @ManyToMany(() => Tag, tag => tag.products)
    @JoinTable({
        name: 'product_tags',
        joinColumn: { name: 'product_id' },
        inverseJoinColumn: { name: 'tag_id' }
    })
    tags: Tag[];
}

@Entity()
class Tag {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @ManyToMany(() => Product, product => product.tags)
    products: Product[];
}

データの取り扱い:

// タグ付きの商品を作成する
const product = new Product();
product.name = 'MacBook Pro';
product.price = 150000;

const tag1 = await tagRepository.findOne({ where: { name: 'Apple' } });
const tag2 = await tagRepository.findOne({ where: { name: 'Premium' } });

product.tags = [tag1, tag2];
await productRepository.save(product);

// すべてのタグを持つ商品を取得します
const productWithTags = await productRepository.findOne({
    where: { id: 1 },
    relations: ['tags']
});

追加データを含む中間表

中間テーブルには、関係だけでなく、追加情報を保存する必要がある場合があります。たとえば、コースシステムでは、学生の登録日と進捗状況を知る必要がある場合があります。

CREATE TABLE students (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL
);

CREATE TABLE courses (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    duration_hours INTEGER NOT NULL
);

CREATE TABLE enrollments (
    id SERIAL PRIMARY KEY,
    student_id INTEGER NOT NULL,
    course_id INTEGER NOT NULL,
    enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    progress INTEGER DEFAULT 0,
    completed BOOLEAN DEFAULT false,
    FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
    FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE,
    UNIQUE(student_id, course_id)
);

ここでは、enrollmentsは単なる結合テーブルではなく、独自の属性を持つ本格的なエンティティです。

TypeORMでは、このような関係を明示的に記述する必要があります。

@Entity()
class Enrollment {
    @PrimaryGeneratedColumn()
    id: number;

    @ManyToOne(() => Student, student => student.enrollments)
    student: Student;

    @ManyToOne(() => Course, course => course.enrollments)
    course: Course;

    @Column()
    enrolledAt: Date;

    @Column()
    progress: number;

    @Column()
    completed: boolean;
}

@Entity()
class Student {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @OneToMany(() => Enrollment, enrollment => enrollment.student)
    enrollments: Enrollment[];
}

@Entity()
class Course {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    title: string;

    @OneToMany(() => Enrollment, enrollment => enrollment.course)
    enrollments: Enrollment[];
}

パフォーマンスと最適化

インデックス

外部キーには、常にインデックスを作成してください。これは、JOINクエリのパフォーマンスにとって重要です。

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_product_tags_product_id ON product_tags(product_id);
CREATE INDEX idx_product_tags_tag_id ON product_tags(tag_id);

N+1 問題

これは、関係を扱う際の典型的な問題です。100人のユーザーのリストを受け取り、それぞれの注文を個別のリクエストでアップロードするとします。データベースへの101件のクエリが発生します。

解決策は、イアガーローディングを使用することです。

// 悪い - N+1 問題
const users = await userRepository.find();
for (const user of users) {
    user.orders = await orderRepository.find({ where: { userId: user.id } });
}

// 良い - JOINを使用した1つのクエリ
const users = await userRepository.find({
    relations: ['orders']
});

遅延読み込み vs 早期読み込み

関連するすべてのデータを常にダウンロードする必要はありません。ユーザーが何千もの注文をしており、ユーザーに関する基本情報のみが必要な場合は、すべての注文を一度にダウンロードしないでください。必要に応じて実行してください。

// ユーザーのみを読み込みます
const user = await userRepository.findOne({ where: { id: 1 } });

// 後で必要に応じて注文を読み込む
if (needOrders) {
    const orders = await orderRepository.find({ 
        where: { userId: user.id },
        take: 10,
        order: { createdAt: 'DESC' }
    });
}

よくある間違い

整合性の制限がないこと

FOREIGN KEYON DELETE CASCADE を使用しない場合、存在しないユーザーを参照する注文などの「ハンギング」レコードを取得できます。

-- Плохо
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    total_amount DECIMAL(10, 2)
);

-- Хорошо
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    total_amount DECIMAL(10, 2),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

接続タイプの選択が正しくありません

開発者は、1対多で十分な場合に多対多を使用し、必要以上に構造を複雑にすることがあります。または逆に、ビジネスロジックが多対多を必要とするにもかかわらず、1対多でやろうとします。

常にドメインを分析してください。エンティティAはエンティティBと複数の関係を持つことができますか?逆もまた同様です。

データの複製

初心者の開発者は、リンクを作成する代わりにデータを複製することがあります。

-- Плохо
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_name VARCHAR(100),
    user_email VARCHAR(100),
    user_phone VARCHAR(20),
    total_amount DECIMAL(10, 2)
);

-- Хорошо
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    total_amount DECIMAL(10, 2),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

デノーマライズが正当化される場合

正規化には多くの利点がありますが、パフォーマンスを向上させるためにデータを少し非正規化することが有効な場合があります。たとえば、ユーザーの注文数を常に把握する必要がある場合は、カウンターを追加できます。

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    orders_count INTEGER DEFAULT 0
);

-- Триггер для автоматического обновления счётчика
CREATE OR REPLACE FUNCTION update_orders_count()
RETURNS TRIGGER AS $
BEGIN
    IF TG_OP = 'INSERT' THEN
        UPDATE users SET orders_count = orders_count + 1 
        WHERE id = NEW.user_id;
    ELSIF TG_OP = 'DELETE' THEN
        UPDATE users SET orders_count = orders_count - 1 
        WHERE id = OLD.user_id;
    END IF;
    RETURN NULL;
END;
$ LANGUAGE plpgsql;

CREATE TRIGGER orders_count_trigger
AFTER INSERT OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION update_orders_count();

しかし、正規化解除は、読み取り速度とサポートの複雑さの間のトレードオフであることを忘れないでください。本当に必要な場合にのみ使用してください。

結論

テーブル間の関係を理解することは、リレーショナルデータベースを操作するための基礎です。1対多の関係はほとんどのシナリオをカバーし、実装も簡単です。多対多は中間テーブルを必要としますが、複雑な関係をモデル化する際の柔軟性を提供します。

覚えておくべき重要なポイントは、データの整合性を維持するために外部キーを常に使用し、クエリを最適化するためにインデックスを作成し、N+1 の問題に注意し、アプリケーションのビジネスロジックに基づいて正しい関係タイプを選択することです。

アプリケーション コディック — 初心者向けの開発者のための教育プラットフォームで、Python、JavaScript、HTML、CSS、その他のプログラミング技術に関する構造化されたコースを見つけることができます。

私たちはアクティブな Telegramコミュニティ開発者が互いにタスクを解決し、経験を共有し、新しいテクノロジーについて話し合う場所です。参加する コディク経験豊富な指導者や志を同じくする人々のサポートを受けながら、快適なペースでプログラミングを学ぶことができます。

実際のタスクを練習すると、時間の経過とともに、特定の状況にどのデータ構造が最適であるかを直感的に理解できるようになります。開発を楽しんでください!

🎯先延ばしをやめよう

記事は気に入った?
実践の時間だ!

Kodikでは読むだけでなく、すぐにコードを書く。理論 + 実践 = 本当のスキル。

即座に実践
🧠AIがコードを説明
🏆修了証

登録不要 • カード不要