mirror of
https://github.com/darkrenaissance/darkfi.git
synced 2026-01-10 23:27:56 -05:00
script/research/blockchain-explorer: added base transactions calls
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
-- Database block table definition.
|
||||
-- Database blocks table definition.
|
||||
-- We store data in a usable format.
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
-- Header hash identifier of the block
|
||||
|
||||
@@ -30,7 +30,7 @@ use drk::{
|
||||
|
||||
use crate::BlockchainExplorer;
|
||||
|
||||
// Dtabase SQL table constant names. These have to represent the `block.sql`
|
||||
// Database SQL table constant names. These have to represent the `blocks.sql`
|
||||
// SQL schema.
|
||||
pub const BLOCKS_TABLE: &str = "blocks";
|
||||
|
||||
@@ -81,8 +81,8 @@ impl BlockRecord {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockInfo> for BlockRecord {
|
||||
fn from(block: BlockInfo) -> Self {
|
||||
impl From<&BlockInfo> for BlockRecord {
|
||||
fn from(block: &BlockInfo) -> Self {
|
||||
Self {
|
||||
header_hash: block.hash().to_string(),
|
||||
version: block.header.version,
|
||||
|
||||
@@ -47,10 +47,14 @@ mod error;
|
||||
mod rpc;
|
||||
mod rpc_blocks;
|
||||
use rpc_blocks::subscribe_blocks;
|
||||
mod rpc_transactions;
|
||||
|
||||
/// Database functionality related to blocks
|
||||
mod blocks;
|
||||
|
||||
/// Database functionality related to transactions
|
||||
mod transactions;
|
||||
|
||||
const CONFIG_FILE: &str = "blockchain_explorer_config.toml";
|
||||
const CONFIG_FILE_CONTENTS: &str = include_str!("../blockchain_explorer_config.toml");
|
||||
|
||||
@@ -153,10 +157,15 @@ impl BlockchainExplorer {
|
||||
// Initialize all the database tables
|
||||
if let Err(e) = explorer.initialize_blocks().await {
|
||||
let err = format!("{e:?}");
|
||||
error!(target: "blockchain-explorer", "Error initializing database tables: {err}");
|
||||
error!(target: "blockchain-explorer", "Error initializing blocks database table: {err}");
|
||||
return Err(Error::RusqliteError(err))
|
||||
}
|
||||
// TODO: map transaction structure to their corresponding files with sql table and retrieval methods
|
||||
if let Err(e) = explorer.initialize_transactions().await {
|
||||
let err = format!("{e:?}");
|
||||
error!(target: "blockchain-explorer", "Error initializing transactions database table: {err}");
|
||||
return Err(Error::RusqliteError(err))
|
||||
}
|
||||
// TODO: Map deployed contracts to their corresponding files with sql table and retrieval methods
|
||||
|
||||
Ok(explorer)
|
||||
}
|
||||
|
||||
@@ -49,18 +49,26 @@ impl RequestHandler for BlockchainExplorer {
|
||||
"ping" => self.pong(req.id, req.params).await,
|
||||
"ping_darkfid" => self.ping_darkfid(req.id, req.params).await,
|
||||
|
||||
// ==================
|
||||
// =====================
|
||||
// Blocks methods
|
||||
// ==================
|
||||
// =====================
|
||||
"blocks.get_last_n_blocks" => self.blocks_get_last_n_blocks(req.id, req.params).await,
|
||||
"blocks.get_blocks_in_heights_range" => {
|
||||
self.blocks_get_blocks_in_heights_range(req.id, req.params).await
|
||||
}
|
||||
"blocks.get_block_by_hash" => self.blocks_get_block_by_hash(req.id, req.params).await,
|
||||
|
||||
// =====================
|
||||
// Transactions methods
|
||||
// =====================
|
||||
"transactions.get_transactions_by_header_hash" => {
|
||||
self.transactions_get_transactions_by_header_hash(req.id, req.params).await
|
||||
}
|
||||
"transactions.get_transaction_by_hash" => {
|
||||
self.transactions_get_transaction_by_hash(req.id, req.params).await
|
||||
}
|
||||
|
||||
// TODO: add statistics retrieval method
|
||||
// TODO: add transactions retrieval method by their block hash
|
||||
// TODO: add transaction retrieval method by its hash
|
||||
// TODO: add any other usefull method
|
||||
|
||||
// ==============
|
||||
|
||||
@@ -56,11 +56,11 @@ impl BlockchainExplorer {
|
||||
}
|
||||
|
||||
/// Syncs the blockchain starting from the last synced block.
|
||||
/// If reset flag is provided, all tables are reset, and start scanning from beginning.
|
||||
/// If reset flag is provided, all tables are reset, and start syncing from beginning.
|
||||
pub async fn sync_blocks(&self, reset: bool) -> WalletDbResult<()> {
|
||||
// Grab last scanned block height
|
||||
// Grab last synced block height
|
||||
let mut height = self.last_block().await?;
|
||||
// If last scanned block is genesis (0) or reset flag
|
||||
// If last synced block is genesis (0) or reset flag
|
||||
// has been provided we reset, otherwise continue with
|
||||
// the next block height
|
||||
if height == 0 || reset {
|
||||
@@ -83,10 +83,10 @@ impl BlockchainExplorer {
|
||||
};
|
||||
let last = *rep.get::<f64>().unwrap() as u32;
|
||||
|
||||
info!(target: "blockchain-explorer::rpc_blocks::sync_blocks", "Requested to scan from block number: {height}");
|
||||
info!(target: "blockchain-explorer::rpc_blocks::sync_blocks", "Requested to sync from block number: {height}");
|
||||
info!(target: "blockchain-explorer::rpc_blocks::sync_blocks", "Last known block number reported by darkfid: {last}");
|
||||
|
||||
// Already scanned last known block
|
||||
// Already synced last known block
|
||||
if height > last {
|
||||
return Ok(())
|
||||
}
|
||||
@@ -102,11 +102,20 @@ impl BlockchainExplorer {
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = self.put_block(&block.into()).await {
|
||||
error!(target: "blockchain-explorer::rpc_blocks::sync_blocks", "[sync_blocks] Scan block failed: {e:?}");
|
||||
if let Err(e) = self.put_block(&(&block).into()).await {
|
||||
error!(target: "blockchain-explorer::rpc_blocks::sync_blocks", "[sync_blocks] Insert block failed: {e:?}");
|
||||
return Err(WalletDbError::GenericError)
|
||||
};
|
||||
|
||||
let block_hash = block.hash().to_string();
|
||||
for transaction in block.txs {
|
||||
if let Err(e) = self.put_transaction(&(&block_hash, &transaction).into()).await
|
||||
{
|
||||
error!(target: "blockchain-explorer::rpc_blocks::sync_blocks", "[sync_blocks] Insert block transaction failed: {e:?}");
|
||||
return Err(WalletDbError::GenericError)
|
||||
};
|
||||
}
|
||||
|
||||
height += 1;
|
||||
}
|
||||
}
|
||||
@@ -244,20 +253,20 @@ pub async fn subscribe_blocks(
|
||||
.darkfid_daemon_request("blockchain.last_known_block", &JsonValue::Array(vec![]))
|
||||
.await?;
|
||||
let last_known = *rep.get::<f64>().unwrap() as u32;
|
||||
let last_scanned = match explorer.last_block().await {
|
||||
let last_synced = match explorer.last_block().await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
return Err(Error::RusqliteError(format!(
|
||||
"[subscribe_blocks] Retrieving last scanned block failed: {e:?}"
|
||||
"[subscribe_blocks] Retrieving last synced block failed: {e:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
if last_known != last_scanned {
|
||||
warn!(target: "blockchain-explorer::rpc_blocks::subscribe_blocks", "Warning: Last scanned block is not the last known block.");
|
||||
warn!(target: "blockchain-explorer::rpc_blocks::subscribe_blocks", "You should first fully scan the blockchain, and then subscribe");
|
||||
if last_known != last_synced {
|
||||
warn!(target: "blockchain-explorer::rpc_blocks::subscribe_blocks", "Warning: Last synced block is not the last known block.");
|
||||
warn!(target: "blockchain-explorer::rpc_blocks::subscribe_blocks", "You should first fully sync the blockchain, and then subscribe");
|
||||
return Err(Error::RusqliteError(
|
||||
"[subscribe_blocks] Blockchain not fully scanned".to_string(),
|
||||
"[subscribe_blocks] Blockchain not fully synced".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -332,11 +341,20 @@ pub async fn subscribe_blocks(
|
||||
info!(target: "blockchain-explorer::rpc_blocks::subscribe_blocks", "=======================================");
|
||||
|
||||
info!(target: "blockchain-explorer::rpc_blocks::subscribe_blocks", "Deserialized successfully. Storring block...");
|
||||
if let Err(e) = explorer.put_block(&block_data.into()).await {
|
||||
if let Err(e) = explorer.put_block(&(&block_data).into()).await {
|
||||
return Err(Error::RusqliteError(format!(
|
||||
"[subscribe_blocks] Scanning block failed: {e:?}"
|
||||
"[subscribe_blocks] Insert block failed: {e:?}"
|
||||
)))
|
||||
}
|
||||
|
||||
let block_hash = block_data.hash().to_string();
|
||||
for transaction in block_data.txs {
|
||||
if let Err(e) = explorer.put_transaction(&(&block_hash, &transaction).into()).await {
|
||||
return Err(Error::RusqliteError(format!(
|
||||
"[subscribe_blocks] Insert block transaction failed: {e:?}"
|
||||
)))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
script/research/blockchain-explorer/src/rpc_transactions.rs
Normal file
101
script/research/blockchain-explorer/src/rpc_transactions.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
/* This file is part of DarkFi (https://dark.fi)
|
||||
*
|
||||
* Copyright (C) 2020-2024 Dyne.org foundation
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use log::error;
|
||||
use tinyjson::JsonValue;
|
||||
|
||||
use darkfi::rpc::jsonrpc::{
|
||||
ErrorCode::{InternalError, InvalidParams},
|
||||
JsonError, JsonResponse, JsonResult,
|
||||
};
|
||||
|
||||
use crate::BlockchainExplorer;
|
||||
|
||||
impl BlockchainExplorer {
|
||||
// RPCAPI:
|
||||
// Queries the database to retrieve the transactions corresponding to the provided block header hash.
|
||||
// Returns the readable transactions upon success.
|
||||
//
|
||||
// **Params:**
|
||||
// * `array[0]`: `String` Block header hash
|
||||
//
|
||||
// **Returns:**
|
||||
// * Array of `TransactionRecord` encoded into a JSON.
|
||||
//
|
||||
// --> {"jsonrpc": "2.0", "method": "transactions.get_transactions_by_header_hash", "params": ["5cc...2f9"], "id": 1}
|
||||
// <-- {"jsonrpc": "2.0", "result": {...}, "id": 1}
|
||||
pub async fn transactions_get_transactions_by_header_hash(
|
||||
&self,
|
||||
id: u16,
|
||||
params: JsonValue,
|
||||
) -> JsonResult {
|
||||
let params = params.get::<Vec<JsonValue>>().unwrap();
|
||||
if params.len() != 1 || !params[0].is_string() {
|
||||
return JsonError::new(InvalidParams, None, id).into()
|
||||
}
|
||||
|
||||
let header_hash = params[0].get::<String>().unwrap();
|
||||
let transactions = match self.get_transactions_by_header_hash(header_hash) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!(target: "blockchain-explorer::rpc_transactions::transactions_get_transaction_by_header_hash", "Failed fetching block transactions: {}", e);
|
||||
return JsonError::new(InternalError, None, id).into()
|
||||
}
|
||||
};
|
||||
|
||||
let mut ret = vec![];
|
||||
for transaction in transactions {
|
||||
ret.push(transaction.to_json_array());
|
||||
}
|
||||
JsonResponse::new(JsonValue::Array(ret), id).into()
|
||||
}
|
||||
|
||||
// RPCAPI:
|
||||
// Queries the database to retrieve the transaction corresponding to the provided hash.
|
||||
// Returns the readable transaction upon success.
|
||||
//
|
||||
// **Params:**
|
||||
// * `array[0]`: `String` Transaction hash
|
||||
//
|
||||
// **Returns:**
|
||||
// * `TransactionRecord` encoded into a JSON.
|
||||
//
|
||||
// --> {"jsonrpc": "2.0", "method": "transactions.get_transaction_by_hash", "params": ["7e7...b4d"], "id": 1}
|
||||
// <-- {"jsonrpc": "2.0", "result": {...}, "id": 1}
|
||||
pub async fn transactions_get_transaction_by_hash(
|
||||
&self,
|
||||
id: u16,
|
||||
params: JsonValue,
|
||||
) -> JsonResult {
|
||||
let params = params.get::<Vec<JsonValue>>().unwrap();
|
||||
if params.len() != 1 || !params[0].is_string() {
|
||||
return JsonError::new(InvalidParams, None, id).into()
|
||||
}
|
||||
|
||||
let transaction_hash = params[0].get::<String>().unwrap();
|
||||
let transaction = match self.get_transaction_by_hash(transaction_hash) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!(target: "blockchain-explorer::rpc_transactions::transactions_get_transaction_by_hash", "Failed fetching transaction: {}", e);
|
||||
return JsonError::new(InternalError, None, id).into()
|
||||
}
|
||||
};
|
||||
|
||||
JsonResponse::new(transaction.to_json_array(), id).into()
|
||||
}
|
||||
}
|
||||
200
script/research/blockchain-explorer/src/transactions.rs
Normal file
200
script/research/blockchain-explorer/src/transactions.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
/* This file is part of DarkFi (https://dark.fi)
|
||||
*
|
||||
* Copyright (C) 2020-2024 Dyne.org foundation
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use log::info;
|
||||
use rusqlite::types::Value;
|
||||
use tinyjson::JsonValue;
|
||||
|
||||
use darkfi::{tx::Transaction, Error, Result};
|
||||
use darkfi_serial::{deserialize, serialize};
|
||||
use drk::{convert_named_params, error::WalletDbResult};
|
||||
|
||||
use crate::BlockchainExplorer;
|
||||
|
||||
// Database SQL table constant names. These have to represent the `transactions.sql`
|
||||
// SQL schema.
|
||||
pub const TRANSACTIONS_TABLE: &str = "transactions";
|
||||
|
||||
// TRANSACTIONS_TABLE
|
||||
pub const TRANSACTIONS_COL_TRANSACTION_HASH: &str = "transaction_hash";
|
||||
pub const TRANSACTIONS_COL_HEADER_HASH: &str = "header_hash";
|
||||
pub const TRANSACTIONS_COL_PAYLOAD: &str = "payload";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Structure representing a `TRANSACTIONS_TABLE` record.
|
||||
pub struct TransactionRecord {
|
||||
/// Transaction hash identifier
|
||||
pub transaction_hash: String,
|
||||
/// Header hash identifier of the block this transaction was included in
|
||||
pub header_hash: String,
|
||||
// TODO: Split the payload into a more easily readable fields
|
||||
/// Transaction payload
|
||||
pub payload: Transaction,
|
||||
}
|
||||
|
||||
impl TransactionRecord {
|
||||
/// Auxiliary function to convert a `TransactionRecord` into a `JsonValue` array.
|
||||
pub fn to_json_array(&self) -> JsonValue {
|
||||
let mut ret = vec![];
|
||||
ret.push(JsonValue::String(self.transaction_hash.clone()));
|
||||
ret.push(JsonValue::String(self.header_hash.clone()));
|
||||
ret.push(JsonValue::String(format!("{:?}", self.payload)));
|
||||
JsonValue::Array(ret)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(&String, &Transaction)> for TransactionRecord {
|
||||
fn from((header_hash, transaction): (&String, &Transaction)) -> Self {
|
||||
Self {
|
||||
transaction_hash: transaction.hash().to_string(),
|
||||
header_hash: header_hash.clone(),
|
||||
payload: transaction.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockchainExplorer {
|
||||
/// Initialize database with transactions tables.
|
||||
pub async fn initialize_transactions(&self) -> WalletDbResult<()> {
|
||||
// Initialize transactions database schema
|
||||
let database_schema = include_str!("../transactions.sql");
|
||||
self.database.exec_batch_sql(database_schema)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset transactions table in the database.
|
||||
pub fn reset_transactions(&self) -> WalletDbResult<()> {
|
||||
info!(target: "blockchain-explorer::transactions::reset_transactions", "Resetting transactions...");
|
||||
let query = format!("DELETE FROM {};", TRANSACTIONS_TABLE);
|
||||
self.database.exec_sql(&query, &[])
|
||||
}
|
||||
|
||||
/// Import given transaction into the database.
|
||||
pub async fn put_transaction(&self, transaction: &TransactionRecord) -> Result<()> {
|
||||
let query = format!(
|
||||
"INSERT OR REPLACE INTO {} ({}, {}, {}) VALUES (?1, ?2, ?3);",
|
||||
TRANSACTIONS_TABLE,
|
||||
TRANSACTIONS_COL_TRANSACTION_HASH,
|
||||
TRANSACTIONS_COL_HEADER_HASH,
|
||||
TRANSACTIONS_COL_PAYLOAD
|
||||
);
|
||||
|
||||
if let Err(e) = self.database.exec_sql(
|
||||
&query,
|
||||
rusqlite::params![
|
||||
transaction.transaction_hash,
|
||||
transaction.header_hash,
|
||||
serialize(&transaction.payload),
|
||||
],
|
||||
) {
|
||||
return Err(Error::RusqliteError(format!(
|
||||
"[put_transaction] Transaction insert failed: {e:?}"
|
||||
)))
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Auxiliary function to parse a `TRANSACTIONS_TABLE` record.
|
||||
fn parse_transaction_record(&self, row: &[Value]) -> Result<TransactionRecord> {
|
||||
let Value::Text(ref transaction_hash) = row[0] else {
|
||||
return Err(Error::ParseFailed(
|
||||
"[parse_transaction_record] Transaction hash parsing failed",
|
||||
))
|
||||
};
|
||||
let transaction_hash = transaction_hash.clone();
|
||||
|
||||
let Value::Text(ref header_hash) = row[1] else {
|
||||
return Err(Error::ParseFailed("[parse_transaction_record] Header hash parsing failed"))
|
||||
};
|
||||
let header_hash = header_hash.clone();
|
||||
|
||||
let Value::Blob(ref payload_bytes) = row[2] else {
|
||||
return Err(Error::ParseFailed(
|
||||
"[parse_transaction_record] Payload bytes bytes parsing failed",
|
||||
))
|
||||
};
|
||||
let payload = deserialize(payload_bytes)?;
|
||||
|
||||
Ok(TransactionRecord { transaction_hash, header_hash, payload })
|
||||
}
|
||||
|
||||
/// Fetch all known transactions from the database.
|
||||
pub fn get_transactions(&self) -> Result<Vec<TransactionRecord>> {
|
||||
let rows = match self.database.query_multiple(TRANSACTIONS_TABLE, &[], &[]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Err(Error::RusqliteError(format!(
|
||||
"[get_transactions] Transactions retrieval failed: {e:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let mut transactions = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
transactions.push(self.parse_transaction_record(&row)?);
|
||||
}
|
||||
|
||||
Ok(transactions)
|
||||
}
|
||||
|
||||
/// Fetch all transactions from the database for the given block header hash.
|
||||
pub fn get_transactions_by_header_hash(
|
||||
&self,
|
||||
header_hash: &str,
|
||||
) -> Result<Vec<TransactionRecord>> {
|
||||
let rows = match self.database.query_multiple(
|
||||
TRANSACTIONS_TABLE,
|
||||
&[],
|
||||
convert_named_params! {(TRANSACTIONS_COL_HEADER_HASH, header_hash)},
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Err(Error::RusqliteError(format!(
|
||||
"[get_transactions_by_header_hash] Transactions retrieval failed: {e:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let mut transactions = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
transactions.push(self.parse_transaction_record(&row)?);
|
||||
}
|
||||
|
||||
Ok(transactions)
|
||||
}
|
||||
|
||||
/// Fetch a transaction given its header hash.
|
||||
pub fn get_transaction_by_hash(&self, transaction_hash: &str) -> Result<TransactionRecord> {
|
||||
let row = match self.database.query_single(
|
||||
TRANSACTIONS_TABLE,
|
||||
&[],
|
||||
convert_named_params! {(TRANSACTIONS_COL_TRANSACTION_HASH, transaction_hash)},
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Err(Error::RusqliteError(format!(
|
||||
"[get_transaction_by_hash] Transaction retrieval failed: {e:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
self.parse_transaction_record(&row)
|
||||
}
|
||||
}
|
||||
14
script/research/blockchain-explorer/transactions.sql
Normal file
14
script/research/blockchain-explorer/transactions.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Database transactions table definition.
|
||||
-- We store data in a usable format.
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
-- Transaction hash identifier
|
||||
transaction_hash TEXT PRIMARY KEY NOT NULL,
|
||||
-- Header hash identifier of the block this transaction was included in
|
||||
header_hash TEXT NOT NULL,
|
||||
-- TODO: Split the payload into a more easily readable fields
|
||||
-- Transaction payload
|
||||
payload BLOB NOT NULL,
|
||||
|
||||
FOREIGN KEY(header_hash) REFERENCES blocks(header_hash) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user