Melewati parameter array ke prosedur tersimpan


53

Saya punya proses yang mengambil banyak catatan (1000-an) dan beroperasi pada mereka, dan ketika saya selesai, saya perlu menandai sejumlah besar dari mereka sebagai diproses. Saya dapat menunjukkan ini dengan daftar besar ID. Saya mencoba untuk menghindari pola "pembaruan dalam satu lingkaran", jadi saya ingin menemukan cara yang lebih efisien untuk mengirim tas ID ini ke dalam proc MS SQL Server 2008 yang disimpan.

Proposal # 1 - Parameter Tabel Bernilai. Saya bisa mendefinisikan tipe tabel w / hanya bidang ID dan mengirim tabel penuh ID untuk memperbarui.

Proposal # 2 - Parameter XML (varchar) dengan OPENXML () di badan proc.

Proposal # 3 - Daftar parsing. Saya lebih suka menghindari ini, jika mungkin, karena tampaknya sulit dan rawan kesalahan.

Adakah preferensi di antara ini, atau ada ide yang saya lewatkan?


Bagaimana Anda mendapatkan daftar besar ID?
Larry Coleman

Saya menarik mereka bersama dengan data "payload" melalui proc lain yang tersimpan. Saya tidak perlu memperbarui semua data itu - cukup perbarui bendera pada catatan tertentu.
D. Lambert

Jawaban:


42

Artikel terbaik yang pernah ada tentang masalah ini adalah oleh Erland Sommarskog:

Dia mencakup semua opsi dan menjelaskan dengan cukup baik.

Maaf atas jawaban yang singkat, tetapi artikel Erland tentang Array adalah seperti buku-buku Joe Celko tentang pohon dan suguhan SQL lainnya :)


23

Ada diskusi hebat tentang ini di StackOverflow yang mencakup banyak pendekatan. Yang saya lebih suka untuk SQL Server 2008+ adalah menggunakan parameter tabel-dihargai . Ini pada dasarnya adalah solusi SQL Server untuk masalah Anda - mengirimkan daftar nilai ke prosedur tersimpan.

Keuntungan dari pendekatan ini adalah:

  • buat satu panggilan prosedur tersimpan dengan semua data Anda dimasukkan sebagai 1 parameter
  • input tabel terstruktur dan sangat diketik
  • tidak ada pembuatan string / parsing atau penanganan XML
  • dapat dengan mudah menggunakan input tabel untuk memfilter, bergabung, atau apa pun

Namun, perhatikan: Jika Anda memanggil prosedur tersimpan yang menggunakan TVP melalui ADO.NET atau ODBC dan melihat aktivitas dengan SQL Server Profiler, Anda akan melihat bahwa SQL Server menerima beberapa INSERTpernyataan untuk memuat TVP, satu untuk setiap baris di TVP , diikuti dengan panggilan ke prosedur. Ini dengan desain . Kumpulan ini INSERTperlu dikompilasi setiap kali prosedur dipanggil, dan merupakan overhead yang kecil. Namun, bahkan dengan overhead ini, TVP masih menyingkirkan pendekatan lain dalam hal kinerja dan kegunaan untuk sebagian besar kasus penggunaan.

Jika Anda ingin mempelajari lebih lanjut, Erland Sommarskog memiliki informasi lengkap tentang cara kerja parameter yang dihargai tabel dan memberikan beberapa contoh.

Berikut adalah contoh lain yang saya buat:

CREATE TYPE id_list AS TABLE (
    id int NOT NULL PRIMARY KEY
);
GO

CREATE PROCEDURE [dbo].[tvp_test] (
      @param1           INT
    , @customer_list    id_list READONLY
)
AS
BEGIN
    SELECT @param1 AS param1;

    -- join, filter, do whatever you want with this table 
    -- (other than modify it)
    SELECT *
    FROM @customer_list;
END;
GO

DECLARE @customer_list id_list;

INSERT INTO @customer_list (
    id
)
VALUES (1), (2), (3), (4), (5), (6), (7);

EXECUTE [dbo].[tvp_test]
      @param1 = 5
    , @customer_list = @customer_list
;
GO

DROP PROCEDURE dbo.tvp_test;
DROP TYPE id_list;
GO

Ketika saya menjalankan ini saya mendapatkan kesalahan: Msg 2715, Level 16, Negara 3, Prosedur tvp_test, Baris 4 [Baris Mulai Batch 4] Kolom, parameter, atau variabel # 2: Tidak dapat menemukan tipe data id_list. Parameter atau variabel '@customer_list' memiliki tipe data yang tidak valid. Msg 1087, Level 16, Status 1, Prosedur tvp_test, Baris 13 [Garis Mulai Batch 4] Harus mendeklarasikan variabel tabel "@customer_list".
Damian

@Damian - Apakah CREATE TYPEpernyataan di awal berjalan dengan sukses? Versi SQL Server apa yang Anda jalankan?
Nick Chammas

Dalam kode SP Anda memiliki kalimat ini inline `SELECT @ param1 AS param1; ' . Apa tujuannya? Anda tidak menggunakan atau param1 jadi mengapa Anda menempatkan ini sebagai parameter di header SP?
EAmez

@EAmez - Itu hanya contoh sewenang-wenang. Intinya @customer_listbukan @param1. Contoh tersebut hanya menunjukkan bahwa Anda dapat mencampur berbagai jenis parameter.
Nick Chammas

21

Seluruh subjek dibahas di dalam artikel definitif oleh Erland Sommarskog: "Array dan Daftar di SQL Server" . Pilih versi mana yang akan dipilih.

Ringkasan, untuk pra SQL Server 2008 di mana TVP mengalahkan sisanya

  • CSV, bagilah sesuka Anda (biasanya saya menggunakan tabel Angka)
  • XML dan parse (lebih baik dengan SQL Server 2005+)
  • Buat tabel sementara di klien

Artikel ini layak dibaca untuk melihat teknik dan pemikiran lain.

Sunting: jawaban terlambat untuk daftar besar di tempat lain: Melewati parameter array ke prosedur tersimpan


14

Saya tahu saya terlambat untuk pesta ini, tapi saya punya masalah di masa lalu, harus mengirim hingga 100 ribu angka bigint, dan melakukan beberapa tolok ukur. Kami akhirnya mengirim mereka dalam format biner, sebagai gambar - yang lebih cepat dari yang lainnya hingga 100 ribu angka.

Ini kode lama saya (SQL Server 2005):

SELECT  Number * 8 + 1 AS StartFrom ,
        Number * 8 + 8 AS MaxLen
INTO    dbo.ParsingNumbers
FROM    dbo.Numbers
GO

CREATE FUNCTION dbo.ParseImageIntoBIGINTs ( @BIGINTs IMAGE )
RETURNS TABLE
AS RETURN
    ( SELECT    CAST(SUBSTRING(@BIGINTs, StartFrom, 8) AS BIGINT) Num
      FROM      dbo.ParsingNumbers
      WHERE     MaxLen <= DATALENGTH(@BIGINTs)
    )
GO

Kode berikut mengemas integer ke dalam gumpalan biner. Saya membalik urutan byte di sini:

static byte[] UlongsToBytes(ulong[] ulongs)
{
int ifrom = ulongs.GetLowerBound(0);
int ito   = ulongs.GetUpperBound(0);
int l = (ito - ifrom + 1)*8;
byte[] ret = new byte[l];
int retind = 0;
for(int i=ifrom; i<=ito; i++)
{
ulong v = ulongs[i];
ret[retind++] = (byte) (v >> 0x38);
ret[retind++] = (byte) (v >> 0x30);
ret[retind++] = (byte) (v >> 40);
ret[retind++] = (byte) (v >> 0x20);
ret[retind++] = (byte) (v >> 0x18);
ret[retind++] = (byte) (v >> 0x10);
ret[retind++] = (byte) (v >> 8);
ret[retind++] = (byte) v;
}
return ret;
}

9

Saya bingung antara merujuk Anda ke SO atau menjawabnya di sini, karena ini hampir merupakan pertanyaan pemrograman. Tapi karena saya sudah punya solusi saya menggunakan ... Saya akan memposting itu;)

Cara ini bekerja adalah Anda memberi string yang dibatasi koma (split sederhana, tidak melakukan pemisahan gaya CSV) ke dalam prosedur tersimpan sebagai varchar (4000) dan kemudian mengumpankan daftar itu ke dalam fungsi ini dan mendapatkan tabel yang berguna kembali, meja varchars yang adil.

Ini memungkinkan Anda untuk mengirimkan nilai hanya id yang ingin Anda proses, dan Anda dapat melakukan penggabungan sederhana pada titik itu.

Bergantian Anda bisa melakukan sesuatu dengan CLR DataTable dan memasukkan itu, tapi itu sedikit lebih banyak untuk mendukung dan semua orang mengerti daftar CSV.

USE [Database]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

ALTER FUNCTION [dbo].[splitListToTable] (@list      nvarchar(MAX), @delimiter nchar(1) = N',')
      RETURNS @tbl TABLE (value     varchar(4000)      NOT NULL) AS
/*
http://www.sommarskog.se/arrays-in-sql.html
This guy is apparently THE guy in SQL arrays and lists 

Need an easy non-dynamic way to split a list of strings on input for comparisons

Usage like thus:

DECLARE @sqlParam VARCHAR(MAX)
SET @sqlParam = 'a,b,c'

SELECT * FROM (

select 'a' as col1, '1' as col2 UNION
select 'a' as col1, '2' as col2 UNION
select 'b' as col1, '3' as col2 UNION
select 'b' as col1, '4' as col2 UNION
select 'c' as col1, '5' as col2 UNION
select 'c' as col1, '6' as col2 ) x 
WHERE EXISTS( SELECT value FROM splitListToTable(@sqlParam,',') WHERE x.col1 = value )

*/
BEGIN
   DECLARE @endpos   int,
           @startpos int,
           @textpos  int,
           @chunklen smallint,
           @tmpstr   nvarchar(4000),
           @leftover nvarchar(4000),
           @tmpval   nvarchar(4000)

   SET @textpos = 1
   SET @leftover = ''
   WHILE @textpos <= datalength(@list) / 2
   BEGIN
      SET @chunklen = 4000 - datalength(@leftover) / 2
      SET @tmpstr = @leftover + substring(@list, @textpos, @chunklen)
      SET @textpos = @textpos + @chunklen

      SET @startpos = 0
      SET @endpos = charindex(@delimiter, @tmpstr)

      WHILE @endpos > 0
      BEGIN
         SET @tmpval = ltrim(rtrim(substring(@tmpstr, @startpos + 1,
                                             @endpos - @startpos - 1)))
         INSERT @tbl (value) VALUES(@tmpval)
         SET @startpos = @endpos
         SET @endpos = charindex(@delimiter, @tmpstr, @startpos + 1)
      END

      SET @leftover = right(@tmpstr, datalength(@tmpstr) / 2 - @startpos)
   END

   INSERT @tbl(value) VALUES (ltrim(rtrim(@leftover)))
   RETURN
END

Yah, saya secara khusus berusaha menghindari daftar yang dibatasi koma sehingga saya tidak perlu menulis sesuatu seperti itu, tetapi karena sudah ditulis, saya kira saya harus membuang solusi itu kembali ke dalam campuran. ;-)
D. Lambert

1
Saya katakan mencoba dan benar adalah yang termudah. Anda dapat meludahkan daftar yang dipisahkan koma dalam C # dalam hitungan detik kode, dan Anda dapat melemparkannya ke fungsi ini (setelah memasukkannya ke dalam sproc Anda) dengan cukup cepat, dan Anda bahkan tidak perlu sulit untuk memikirkannya. ~ Dan saya tahu Anda mengatakan Anda tidak ingin menggunakan fungsi, tapi saya pikir itu cara paling sederhana (mungkin bukan yang paling efektif)
jcolebrand

5

Saya secara teratur menerima set 1000-an baris dan 10.000-an baris yang dikirim dari aplikasi kita untuk diproses oleh berbagai prosedur yang tersimpan SQL Server.

Untuk memenuhi tuntutan kinerja, kami menggunakan TVP, tetapi Anda harus menerapkan abstrak dbDataReader Anda sendiri untuk mengatasi beberapa masalah kinerja dalam mode pemrosesan standarnya. Saya tidak akan membahas bagaimana dan mengapa mereka berada di luar ruang lingkup untuk permintaan ini.

Saya tidak mempertimbangkan pemrosesan XML karena saya belum menemukan implementasi XML yang tetap memiliki lebih dari 10.000 "baris".

Pemrosesan daftar dapat ditangani dengan pemrosesan tabel penghitungan satu dimensi dan dua dimensi. Kami telah berhasil menggunakan ini di berbagai bidang, tetapi TVP yang dikelola dengan lebih baik lebih berkinerja ketika ada lebih dari beberapa ratus "baris".

Seperti semua pilihan tentang pemrosesan SQL Server, Anda harus menentukan pilihan berdasarkan model penggunaan.


5

Saya akhirnya mendapat kesempatan untuk melakukan beberapa TableValuedParameters dan mereka bekerja dengan baik, jadi saya akan menempelkan seluruh kode lotta yang menunjukkan bagaimana saya menggunakannya, dengan sampel dari beberapa kode saya saat ini: (catatan: kami menggunakan ADO .BERSIH)

Juga perhatikan: Saya menulis beberapa kode untuk layanan, dan saya punya banyak bit kode yang sudah ditentukan sebelumnya di kelas lain, tapi saya menulis ini sebagai aplikasi konsol sehingga saya bisa men-debug-nya, jadi saya ripping semua ini dari aplikasi konsol. Maafkan gaya pengkodean saya (seperti string koneksi hardcoded) karena itu semacam "membangun satu untuk membuang". Saya ingin menunjukkan bagaimana saya menggunakan List<customObject>dan mendorongnya ke dalam database dengan mudah sebagai sebuah tabel, yang dapat saya gunakan dalam prosedur tersimpan. Kode C # dan TSQL di bawah ini:

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using a;

namespace a.EventAMI {
    class Db {
        private static SqlCommand SqlCommandFactory(string sprocName, SqlConnection con) { return new SqlCommand { CommandType = CommandType.StoredProcedure, CommandText = sprocName, CommandTimeout = 0, Connection = con }; }

        public static void Update(List<Current> currents) {
            const string CONSTR = @"just a hardwired connection string while I'm debugging";
            SqlConnection con = new SqlConnection( CONSTR );

            SqlCommand cmd = SqlCommandFactory( "sprocname", con );
            cmd.Parameters.Add( "@CurrentTVP", SqlDbType.Structured ).Value = Converter.GetDataTableFromIEnumerable( currents, typeof( Current ) ); //my custom converter class

            try {
                using ( con ) {
                    con.Open();
                    cmd.ExecuteNonQuery();
                }
            } catch ( Exception ex ) {
                ErrHandler.WriteXML( ex );
                throw;
            }
        }
    }
    class Current {
        public string Identifier { get; set; }
        public string OffTime { get; set; }
        public DateTime Off() {
            return Convert.ToDateTime( OffTime );
        }

        private static SqlCommand SqlCommandFactory(string sprocName, SqlConnection con) { return new SqlCommand { CommandType = CommandType.StoredProcedure, CommandText = sprocName, CommandTimeout = 0, Connection = con }; }

        public static List<Current> GetAll() {
            List<Current> l = new List<Current>();

            const string CONSTR = @"just a hardcoded connection string while I'm debugging";
            SqlConnection con = new SqlConnection( CONSTR );

            SqlCommand cmd = SqlCommandFactory( "sprocname", con );

            try {
                using ( con ) {
                    con.Open();
                    using ( SqlDataReader reader = cmd.ExecuteReader() ) {
                        while ( reader.Read() ) {
                            l.Add(
                                new Current {
                                    Identifier = reader[0].ToString(),
                                    OffTime = reader[1].ToString()
                                } );
                        }
                    }

                }
            } catch ( Exception ex ) {
                ErrHandler.WriteXML( ex );
                throw;
            }

            return l;
        }
    }
}

-------------------
the converter class
-------------------
using System;
using System.Collections;
using System.Data;
using System.Reflection;

namespace a {
    public static class Converter {
        public static DataTable GetDataTableFromIEnumerable(IEnumerable aIEnumerable) {
            return GetDataTableFromIEnumerable( aIEnumerable, null );
        }

        public static DataTable GetDataTableFromIEnumerable(IEnumerable aIEnumerable, Type baseType) {
            DataTable returnTable = new DataTable();

            if ( aIEnumerable != null ) {
                //Creates the table structure looping in the in the first element of the list
                object baseObj = null;

                Type objectType;

                if ( baseType == null ) {
                    foreach ( object obj in aIEnumerable ) {
                        baseObj = obj;
                        break;
                    }

                    objectType = baseObj.GetType();
                } else {
                    objectType = baseType;
                }

                PropertyInfo[] properties = objectType.GetProperties();

                DataColumn col;

                foreach ( PropertyInfo property in properties ) {
                    col = new DataColumn { ColumnName = property.Name };
                    if ( property.PropertyType == typeof( DateTime? ) ) {
                        col.DataType = typeof( DateTime );
                    } else if ( property.PropertyType == typeof( Int32? ) ) {
                        col.DataType = typeof( Int32 );
                    } else {
                        col.DataType = property.PropertyType;
                    }
                    returnTable.Columns.Add( col );
                }

                //Adds the rows to the table

                foreach ( object objItem in aIEnumerable ) {
                    DataRow row = returnTable.NewRow();

                    foreach ( PropertyInfo property in properties ) {
                        Object value = property.GetValue( objItem, null );
                        if ( value != null )
                            row[property.Name] = value;
                        else
                            row[property.Name] = "";
                    }

                    returnTable.Rows.Add( row );
                }
            }
            return returnTable;
        }

    }
}

USE [Database]
GO

SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

ALTER PROC [dbo].[Event_Update]
    @EventCurrentTVP    Event_CurrentTVP    READONLY
AS

/****************************************************************
    author  cbrand
    date    
    descrip I'll ask you to forgive me the anonymization I've made here, but hope this helps
    caller  such and thus application
****************************************************************/

BEGIN TRAN Event_Update

DECLARE @DEBUG INT

SET @DEBUG = 0 /* test using @DEBUG <> 0 */

/*
    Replace the list of outstanding entries that are still currently disconnected with the list from the file
    This means remove all existing entries (faster to truncate and insert than to delete on a join and insert, yes?)
*/
TRUNCATE TABLE [database].[dbo].[Event_Current]

INSERT INTO [database].[dbo].[Event_Current]
           ([Identifier]
            ,[OffTime])
SELECT [Identifier]
      ,[OffTime]
  FROM @EventCurrentTVP

IF (@@ERROR <> 0 OR @DEBUG <> 0) 
BEGIN
ROLLBACK TRAN Event_Update
END
ELSE
BEGIN
COMMIT TRAN Event_Update
END

USE [Database]
GO

CREATE TYPE [dbo].[Event_CurrentTVP] AS TABLE(
    [Identifier] [varchar](20) NULL,
    [OffTime] [datetime] NULL
)
GO

Juga, saya akan menerima kritik konstruktif pada gaya pengkodean saya jika Anda memiliki itu untuk ditawarkan (kepada semua pembaca yang menemukan pertanyaan ini) tetapi tolong tetap konstruktif;) ... Jika Anda benar-benar menginginkan saya, temukan saya di ruang obrolan di sini . Semoga dengan potongan kode ini orang dapat melihat bagaimana mereka dapat menggunakan List<Current>seperti yang saya definisikan sebagai tabel di db dan List<T>di aplikasi mereka.


3

Saya akan pergi dengan proposal # 1 atau, sebagai alternatif, membuat tabel awal yang hanya menampung id yang diproses. Masukkan ke dalam tabel itu selama pemrosesan, kemudian setelah selesai, panggil proc seperti di bawah ini:

BEGIN TRAN

UPDATE dt
SET processed = 1
FROM dataTable dt
JOIN processedIds pi ON pi.id = dt.id;

TRUNCATE TABLE processedIds

COMMIT TRAN

Anda akan melakukan banyak sisipan, tetapi mereka akan ke meja kecil, jadi itu harus cepat. Anda juga dapat mengelompokkan sisipan Anda menggunakan ADO.net atau adaptor data apa pun yang Anda gunakan.


2

Judul pertanyaan mencakup tugas untuk mengirimkan data dari aplikasi ke dalam prosedur tersimpan. Bagian itu dikecualikan oleh badan pertanyaan, tetapi izinkan saya mencoba untuk menjawab ini juga.

Dalam konteks sql-server-2008 seperti yang ditentukan oleh tag ada artikel hebat lain oleh E. Sommarskog Array dan Daftar di SQL Server 2008 . BTW saya menemukannya di artikel yang dimaksud Marian dalam jawabannya.

Alih-alih hanya memberikan tautan, saya mengutip daftar isinya:

  • pengantar
  • Latar Belakang
  • Parameter Table-Valued dalam T-SQL
  • Passing Table-Valued Parameters dari ADO .NET
    • Menggunakan Daftar
    • Menggunakan DataTable
    • Menggunakan DataReader
    • Keterangan Terakhir
  • Menggunakan Parameter Table-Valued dari API Lain
    • ODBC
    • OLE DB
    • RIBUT
    • LINQ dan Kerangka Entitas
    • JDBC
    • PHP
    • Perl
    • Bagaimana Jika API Anda Tidak Mendukung TVP
  • Pertimbangan Kinerja
    • Sisi server
    • Sisi klien
    • Kunci Utama atau Tidak?
  • Ucapan Terima Kasih dan Umpan Balik
  • Riwayat Revisi

Di luar teknik yang disebutkan di sana, saya merasa bahwa dalam beberapa kasus, bulkcopy dan bulk insert layak disebutkan dalam lingkup kasus umum.


1

Melewati parameter array ke prosedur tersimpan

Untuk MS SQL 2016 versi terbaru

Dengan MS SQL 2016 mereka memperkenalkan fungsi baru: SPLIT_STRING () untuk mem-parsing beberapa nilai.

Ini dapat memecahkan masalah Anda dengan mudah.

Untuk MS SQL Versi Lama

Jika Anda menggunakan versi yang lebih lama, ikuti langkah ini:

Pertama, buat satu fungsi:

 ALTER FUNCTION [dbo].[UDF_IDListToTable]
 (
    @list          [varchar](MAX),
    @Seperator     CHAR(1)
  )
 RETURNS @tbl TABLE (ID INT)
 WITH 

 EXECUTE AS CALLER
 AS
  BEGIN
    DECLARE @position INT
    DECLARE @NewLine CHAR(2) 
    DECLARE @no INT
    SET @NewLine = CHAR(13) + CHAR(10)

    IF CHARINDEX(@Seperator, @list) = 0
    BEGIN
    INSERT INTO @tbl
    VALUES
      (
        @list
      )
END
ELSE
BEGIN
    SET @position = 1
    SET @list = @list + @Seperator
    WHILE CHARINDEX(@Seperator, @list, @position) <> 0
    BEGIN
        SELECT @no = SUBSTRING(
                   @list,
                   @position,
                   CHARINDEX(@Seperator, @list, @position) - @position
               )

        IF @no <> ''
            INSERT INTO @tbl
            VALUES
              (
                @no
              )

        SET @position = CHARINDEX(@Seperator, @list, @position) + 1
    END
END
RETURN
END

Setelah membuat ini, sampaikan string Anda ke fungsi ini dengan pemisah.

Saya harap ini dapat membantu anda. :-)


-1

Gunakan ini untuk membuat "buat tabel tipe". contoh sederhana untuk pengguna

CREATE TYPE unit_list AS TABLE (
    ItemUnitId int,
    Amount float,
    IsPrimaryUnit bit
);

GO
 CREATE TYPE specification_list AS TABLE (
     ItemSpecificationMasterId int,
    ItemSpecificationMasterValue varchar(255)
);

GO
 declare @units unit_list;
 insert into @units (ItemUnitId, Amount, IsPrimaryUnit) 
  values(12,10.50, false), 120,100.50, false), (1200,500.50, true);

 declare @spec specification_list;
  insert into @spec (ItemSpecificationMasterId,temSpecificationMasterValue) 
   values (12,'test'), (124,'testing value');

 exec sp_add_item "mytests", false, @units, @spec


//Procedure definition
CREATE PROCEDURE sp_add_item
(   
    @Name nvarchar(50),
    @IsProduct bit=false,
    @UnitsArray unit_list READONLY,
    @SpecificationsArray specification_list READONLY
)
AS


BEGIN
    SET NOCOUNT OFF     

    print @Name;
    print @IsProduct;       
    select * from @UnitsArray;
    select * from @SpecificationsArray;
END
Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.